chore(test): add edge case tests for user settings shortcuts and JSON fields

This commit is contained in:
Johnny 2026-01-19 22:50:14 +08:00
parent dc7ec8a8ad
commit af2a2588bf
5 changed files with 570 additions and 244 deletions

View File

@ -564,7 +564,7 @@ func (r *renderer) jsonBoolPredicate(field Field) (string, error) {
case DialectSQLite:
return fmt.Sprintf("%s IS TRUE", expr), nil
case DialectMySQL:
return fmt.Sprintf("%s = CAST('true' AS JSON)", expr), nil
return fmt.Sprintf("COALESCE(%s, CAST('false' AS JSON)) = CAST('true' AS JSON)", expr), nil
case DialectPostgres:
return fmt.Sprintf("(%s)::boolean IS TRUE", expr), nil
default:

View File

@ -254,3 +254,49 @@ func TestInstanceSettingListAll(t *testing.T) {
ts.Close()
}
func TestInstanceSettingEdgeCases(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// Case 1: General Setting with special characters and Unicode
specialScript := `<script>alert("你好"); var x = 'test\'s';</script>`
specialStyle := `body { font-family: "Noto Sans SC", sans-serif; content: "\u2764"; }`
_, err := ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_GENERAL,
Value: &storepb.InstanceSetting_GeneralSetting{
GeneralSetting: &storepb.InstanceGeneralSetting{
AdditionalScript: specialScript,
AdditionalStyle: specialStyle,
},
},
})
require.NoError(t, err)
generalSetting, err := ts.GetInstanceGeneralSetting(ctx)
require.NoError(t, err)
require.Equal(t, specialScript, generalSetting.AdditionalScript)
require.Equal(t, specialStyle, generalSetting.AdditionalStyle)
// Case 2: Memo Related Setting with Unicode reactions
unicodeReactions := []string{"🐱", "🐶", "🦊", "🦄"}
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_MEMO_RELATED,
Value: &storepb.InstanceSetting_MemoRelatedSetting{
MemoRelatedSetting: &storepb.InstanceMemoRelatedSetting{
ContentLengthLimit: 1000,
Reactions: unicodeReactions,
},
},
})
require.NoError(t, err)
memoSetting, err := ts.GetInstanceMemoRelatedSetting(ctx)
require.NoError(t, err)
require.Equal(t, unicodeReactions, memoSetting.Reactions)
ts.Close()
}

View File

@ -1,243 +0,0 @@
package test
import (
"testing"
"github.com/stretchr/testify/require"
)
// =============================================================================
// Tag Comprehension Tests (exists macro)
// Schema: tags (list of strings, supports exists/all macros with predicates)
// =============================================================================
func TestMemoFilterTagsExistsStartsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with different tags
tc.CreateMemo(NewMemoBuilder("memo-archive1", tc.User.ID).
Content("Archived project memo").
Tags("archive/project", "done"))
tc.CreateMemo(NewMemoBuilder("memo-archive2", tc.User.ID).
Content("Archived work memo").
Tags("archive/work", "old"))
tc.CreateMemo(NewMemoBuilder("memo-active", tc.User.ID).
Content("Active project memo").
Tags("project/active", "todo"))
tc.CreateMemo(NewMemoBuilder("memo-homelab", tc.User.ID).
Content("Homelab memo").
Tags("homelab/memos", "tech"))
// Test: tags.exists(t, t.startsWith("archive")) - should match archived memos
memos := tc.ListWithFilter(`tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 2, "Should find 2 archived memos")
for _, memo := range memos {
hasArchiveTag := false
for _, tag := range memo.Payload.Tags {
if len(tag) >= 7 && tag[:7] == "archive" {
hasArchiveTag = true
break
}
}
require.True(t, hasArchiveTag, "Memo should have tag starting with 'archive'")
}
// Test: !tags.exists(t, t.startsWith("archive")) - should match non-archived memos
memos = tc.ListWithFilter(`!tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 2, "Should find 2 non-archived memos")
// Test: tags.exists(t, t.startsWith("project")) - should match project memos
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("project"))`)
require.Len(t, memos, 1, "Should find 1 project memo")
// Test: tags.exists(t, t.startsWith("homelab")) - should match homelab memos
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("homelab"))`)
require.Len(t, memos, 1, "Should find 1 homelab memo")
// Test: tags.exists(t, t.startsWith("nonexistent")) - should match nothing
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("nonexistent"))`)
require.Len(t, memos, 0, "Should find no memos")
}
func TestMemoFilterTagsExistsContains(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with different tags
tc.CreateMemo(NewMemoBuilder("memo-todo1", tc.User.ID).
Content("Todo task 1").
Tags("project/todo", "urgent"))
tc.CreateMemo(NewMemoBuilder("memo-todo2", tc.User.ID).
Content("Todo task 2").
Tags("work/todo-list", "pending"))
tc.CreateMemo(NewMemoBuilder("memo-done", tc.User.ID).
Content("Done task").
Tags("project/completed", "done"))
// Test: tags.exists(t, t.contains("todo")) - should match todos
memos := tc.ListWithFilter(`tags.exists(t, t.contains("todo"))`)
require.Len(t, memos, 2, "Should find 2 todo memos")
// Test: tags.exists(t, t.contains("done")) - should match done
memos = tc.ListWithFilter(`tags.exists(t, t.contains("done"))`)
require.Len(t, memos, 1, "Should find 1 done memo")
// Test: !tags.exists(t, t.contains("todo")) - should exclude todos
memos = tc.ListWithFilter(`!tags.exists(t, t.contains("todo"))`)
require.Len(t, memos, 1, "Should find 1 non-todo memo")
}
func TestMemoFilterTagsExistsEndsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with different tag endings
tc.CreateMemo(NewMemoBuilder("memo-bug", tc.User.ID).
Content("Bug report").
Tags("project/bug", "critical"))
tc.CreateMemo(NewMemoBuilder("memo-debug", tc.User.ID).
Content("Debug session").
Tags("work/debug", "dev"))
tc.CreateMemo(NewMemoBuilder("memo-feature", tc.User.ID).
Content("New feature").
Tags("project/feature", "new"))
// Test: tags.exists(t, t.endsWith("bug")) - should match bug-related tags
memos := tc.ListWithFilter(`tags.exists(t, t.endsWith("bug"))`)
require.Len(t, memos, 2, "Should find 2 bug-related memos")
// Test: tags.exists(t, t.endsWith("feature")) - should match feature
memos = tc.ListWithFilter(`tags.exists(t, t.endsWith("feature"))`)
require.Len(t, memos, 1, "Should find 1 feature memo")
// Test: !tags.exists(t, t.endsWith("bug")) - should exclude bug-related
memos = tc.ListWithFilter(`!tags.exists(t, t.endsWith("bug"))`)
require.Len(t, memos, 1, "Should find 1 non-bug memo")
}
func TestMemoFilterTagsExistsCombinedWithOtherFilters(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with tags and other properties
tc.CreateMemo(NewMemoBuilder("memo-archived-old", tc.User.ID).
Content("Old archived memo").
Tags("archive/old", "done"))
tc.CreateMemo(NewMemoBuilder("memo-archived-recent", tc.User.ID).
Content("Recent archived memo with TODO").
Tags("archive/recent", "done"))
tc.CreateMemo(NewMemoBuilder("memo-active-todo", tc.User.ID).
Content("Active TODO").
Tags("project/active", "todo"))
// Test: Combine tag filter with content filter
memos := tc.ListWithFilter(`tags.exists(t, t.startsWith("archive")) && content.contains("TODO")`)
require.Len(t, memos, 1, "Should find 1 archived memo with TODO in content")
// Test: OR condition with tag filters
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive")) || tags.exists(t, t.contains("todo"))`)
require.Len(t, memos, 3, "Should find all memos (archived or with todo tag)")
// Test: Complex filter - archived but not containing "Recent"
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive")) && !content.contains("Recent")`)
require.Len(t, memos, 1, "Should find 1 old archived memo")
}
func TestMemoFilterTagsExistsEmptyAndNullCases(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memo with no tags
tc.CreateMemo(NewMemoBuilder("memo-no-tags", tc.User.ID).
Content("Memo without tags"))
// Create memo with tags
tc.CreateMemo(NewMemoBuilder("memo-with-tags", tc.User.ID).
Content("Memo with tags").
Tags("tag1", "tag2"))
// Test: tags.exists should not match memos without tags
memos := tc.ListWithFilter(`tags.exists(t, t.startsWith("tag"))`)
require.Len(t, memos, 1, "Should only find memo with tags")
// Test: Negation should match memos without matching tags
memos = tc.ListWithFilter(`!tags.exists(t, t.startsWith("tag"))`)
require.Len(t, memos, 1, "Should find memo without matching tags")
}
// =============================================================================
// Issue #5480 - Real-world use case test
// =============================================================================
func TestMemoFilterIssue5480_ArchiveWorkflow(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create a realistic scenario as described in issue #5480
// User has hierarchical tags and archives memos by prefixing with "archive"
// Active memos
tc.CreateMemo(NewMemoBuilder("memo-homelab", tc.User.ID).
Content("Setting up Memos").
Tags("homelab/memos", "tech"))
tc.CreateMemo(NewMemoBuilder("memo-project-alpha", tc.User.ID).
Content("Project Alpha notes").
Tags("work/project-alpha", "active"))
// Archived memos (user prefixed tags with "archive")
tc.CreateMemo(NewMemoBuilder("memo-old-homelab", tc.User.ID).
Content("Old homelab setup").
Tags("archive/homelab/old-server", "done"))
tc.CreateMemo(NewMemoBuilder("memo-old-project", tc.User.ID).
Content("Old project beta").
Tags("archive/work/project-beta", "completed"))
tc.CreateMemo(NewMemoBuilder("memo-archived-personal", tc.User.ID).
Content("Archived personal note").
Tags("archive/personal/2024", "old"))
// Test: Filter out ALL archived memos using startsWith
memos := tc.ListWithFilter(`!tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 2, "Should only show active memos (not archived)")
for _, memo := range memos {
for _, tag := range memo.Payload.Tags {
require.NotContains(t, tag, "archive", "Active memos should not have archive prefix")
}
}
// Test: Show ONLY archived memos
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 3, "Should find all archived memos")
for _, memo := range memos {
hasArchiveTag := false
for _, tag := range memo.Payload.Tags {
if len(tag) >= 7 && tag[:7] == "archive" {
hasArchiveTag = true
break
}
}
require.True(t, hasArchiveTag, "All returned memos should have archive prefix")
}
// Test: Filter archived homelab memos specifically
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive/homelab"))`)
require.Len(t, memos, 1, "Should find only archived homelab memos")
}

View File

@ -61,6 +61,28 @@ func TestMemoFilterContentUnicode(t *testing.T) {
require.Len(t, memos, 1)
}
func TestMemoFilterContentCaseSensitivity(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
tc.CreateMemo(NewMemoBuilder("memo-case", tc.User.ID).Content("MixedCase Content"))
// Exact match
memos := tc.ListWithFilter(`content.contains("MixedCase")`)
require.Len(t, memos, 1)
// Lowercase match (depends on DB collation, usually case-insensitive in default installs but good to verify behavior)
// SQLite default LIKE is case-insensitive for ASCII.
memosLower := tc.ListWithFilter(`content.contains("mixedcase")`)
// We just verify it doesn't crash; strict case sensitivity expectation depends on DB config.
// For standard Memos setup (SQLite), it's often case-insensitive.
// Let's check if we get a result or not to characterize current behavior.
if len(memosLower) > 0 {
require.Equal(t, "MixedCase Content", memosLower[0].Content)
}
}
// =============================================================================
// Visibility Field Tests
// Schema: visibility (string, ==, !=)
@ -574,6 +596,244 @@ func TestMemoFilterComplexLogical(t *testing.T) {
// Test: (pinned || tag in ["important"]) && visibility == "PUBLIC"
memos = tc.ListWithFilter(`(pinned || tag in ["important"]) && visibility == "PUBLIC"`)
require.Len(t, memos, 3)
// Test: De Morgan's Law ! (A || B) == !A && !B
// ! (pinned || has_task_list)
tc.CreateMemo(NewMemoBuilder("memo-no-props", tc.User.ID).Content("Nothing special"))
memos = tc.ListWithFilter(`!(pinned || has_task_list)`)
require.Len(t, memos, 2) // Unpinned-tagged + Nothing special (pinned-untagged is pinned)
}
// =============================================================================
// Tag Comprehension Tests (exists macro)
// Schema: tags (list of strings, supports exists/all macros with predicates)
// =============================================================================
func TestMemoFilterTagsExistsStartsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with different tags
tc.CreateMemo(NewMemoBuilder("memo-archive1", tc.User.ID).
Content("Archived project memo").
Tags("archive/project", "done"))
tc.CreateMemo(NewMemoBuilder("memo-archive2", tc.User.ID).
Content("Archived work memo").
Tags("archive/work", "old"))
tc.CreateMemo(NewMemoBuilder("memo-active", tc.User.ID).
Content("Active project memo").
Tags("project/active", "todo"))
tc.CreateMemo(NewMemoBuilder("memo-homelab", tc.User.ID).
Content("Homelab memo").
Tags("homelab/memos", "tech"))
// Test: tags.exists(t, t.startsWith("archive")) - should match archived memos
memos := tc.ListWithFilter(`tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 2, "Should find 2 archived memos")
for _, memo := range memos {
hasArchiveTag := false
for _, tag := range memo.Payload.Tags {
if len(tag) >= 7 && tag[:7] == "archive" {
hasArchiveTag = true
break
}
}
require.True(t, hasArchiveTag, "Memo should have tag starting with 'archive'")
}
// Test: !tags.exists(t, t.startsWith("archive")) - should match non-archived memos
memos = tc.ListWithFilter(`!tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 2, "Should find 2 non-archived memos")
// Test: tags.exists(t, t.startsWith("project")) - should match project memos
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("project"))`)
require.Len(t, memos, 1, "Should find 1 project memo")
// Test: tags.exists(t, t.startsWith("homelab")) - should match homelab memos
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("homelab"))`)
require.Len(t, memos, 1, "Should find 1 homelab memo")
// Test: tags.exists(t, t.startsWith("nonexistent")) - should match nothing
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("nonexistent"))`)
require.Len(t, memos, 0, "Should find no memos")
}
func TestMemoFilterTagsExistsContains(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with different tags
tc.CreateMemo(NewMemoBuilder("memo-todo1", tc.User.ID).
Content("Todo task 1").
Tags("project/todo", "urgent"))
tc.CreateMemo(NewMemoBuilder("memo-todo2", tc.User.ID).
Content("Todo task 2").
Tags("work/todo-list", "pending"))
tc.CreateMemo(NewMemoBuilder("memo-done", tc.User.ID).
Content("Done task").
Tags("project/completed", "done"))
// Test: tags.exists(t, t.contains("todo")) - should match todos
memos := tc.ListWithFilter(`tags.exists(t, t.contains("todo"))`)
require.Len(t, memos, 2, "Should find 2 todo memos")
// Test: tags.exists(t, t.contains("done")) - should match done
memos = tc.ListWithFilter(`tags.exists(t, t.contains("done"))`)
require.Len(t, memos, 1, "Should find 1 done memo")
// Test: !tags.exists(t, t.contains("todo")) - should exclude todos
memos = tc.ListWithFilter(`!tags.exists(t, t.contains("todo"))`)
require.Len(t, memos, 1, "Should find 1 non-todo memo")
}
func TestMemoFilterTagsExistsEndsWith(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with different tag endings
tc.CreateMemo(NewMemoBuilder("memo-bug", tc.User.ID).
Content("Bug report").
Tags("project/bug", "critical"))
tc.CreateMemo(NewMemoBuilder("memo-debug", tc.User.ID).
Content("Debug session").
Tags("work/debug", "dev"))
tc.CreateMemo(NewMemoBuilder("memo-feature", tc.User.ID).
Content("New feature").
Tags("project/feature", "new"))
// Test: tags.exists(t, t.endsWith("bug")) - should match bug-related tags
memos := tc.ListWithFilter(`tags.exists(t, t.endsWith("bug"))`)
require.Len(t, memos, 2, "Should find 2 bug-related memos")
// Test: tags.exists(t, t.endsWith("feature")) - should match feature
memos = tc.ListWithFilter(`tags.exists(t, t.endsWith("feature"))`)
require.Len(t, memos, 1, "Should find 1 feature memo")
// Test: !tags.exists(t, t.endsWith("bug")) - should exclude bug-related
memos = tc.ListWithFilter(`!tags.exists(t, t.endsWith("bug"))`)
require.Len(t, memos, 1, "Should find 1 non-bug memo")
}
func TestMemoFilterTagsExistsCombinedWithOtherFilters(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memos with tags and other properties
tc.CreateMemo(NewMemoBuilder("memo-archived-old", tc.User.ID).
Content("Old archived memo").
Tags("archive/old", "done"))
tc.CreateMemo(NewMemoBuilder("memo-archived-recent", tc.User.ID).
Content("Recent archived memo with TODO").
Tags("archive/recent", "done"))
tc.CreateMemo(NewMemoBuilder("memo-active-todo", tc.User.ID).
Content("Active TODO").
Tags("project/active", "todo"))
// Test: Combine tag filter with content filter
memos := tc.ListWithFilter(`tags.exists(t, t.startsWith("archive")) && content.contains("TODO")`)
require.Len(t, memos, 1, "Should find 1 archived memo with TODO in content")
// Test: OR condition with tag filters
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive")) || tags.exists(t, t.contains("todo"))`)
require.Len(t, memos, 3, "Should find all memos (archived or with todo tag)")
// Test: Complex filter - archived but not containing "Recent"
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive")) && !content.contains("Recent")`)
require.Len(t, memos, 1, "Should find 1 old archived memo")
}
func TestMemoFilterTagsExistsEmptyAndNullCases(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create memo with no tags
tc.CreateMemo(NewMemoBuilder("memo-no-tags", tc.User.ID).
Content("Memo without tags"))
// Create memo with tags
tc.CreateMemo(NewMemoBuilder("memo-with-tags", tc.User.ID).
Content("Memo with tags").
Tags("tag1", "tag2"))
// Test: tags.exists should not match memos without tags
memos := tc.ListWithFilter(`tags.exists(t, t.startsWith("tag"))`)
require.Len(t, memos, 1, "Should only find memo with tags")
// Test: Negation should match memos without matching tags
memos = tc.ListWithFilter(`!tags.exists(t, t.startsWith("tag"))`)
require.Len(t, memos, 1, "Should find memo without matching tags")
}
func TestMemoFilterIssue5480_ArchiveWorkflow(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// Create a realistic scenario as described in issue #5480
// User has hierarchical tags and archives memos by prefixing with "archive"
// Active memos
tc.CreateMemo(NewMemoBuilder("memo-homelab", tc.User.ID).
Content("Setting up Memos").
Tags("homelab/memos", "tech"))
tc.CreateMemo(NewMemoBuilder("memo-project-alpha", tc.User.ID).
Content("Project Alpha notes").
Tags("work/project-alpha", "active"))
// Archived memos (user prefixed tags with "archive")
tc.CreateMemo(NewMemoBuilder("memo-old-homelab", tc.User.ID).
Content("Old homelab setup").
Tags("archive/homelab/old-server", "done"))
tc.CreateMemo(NewMemoBuilder("memo-old-project", tc.User.ID).
Content("Old project beta").
Tags("archive/work/project-beta", "completed"))
tc.CreateMemo(NewMemoBuilder("memo-archived-personal", tc.User.ID).
Content("Archived personal note").
Tags("archive/personal/2024", "old"))
// Test: Filter out ALL archived memos using startsWith
memos := tc.ListWithFilter(`!tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 2, "Should only show active memos (not archived)")
for _, memo := range memos {
for _, tag := range memo.Payload.Tags {
require.NotContains(t, tag, "archive", "Active memos should not have archive prefix")
}
}
// Test: Show ONLY archived memos
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive"))`)
require.Len(t, memos, 3, "Should find all archived memos")
for _, memo := range memos {
hasArchiveTag := false
for _, tag := range memo.Payload.Tags {
if len(tag) >= 7 && tag[:7] == "archive" {
hasArchiveTag = true
break
}
}
require.True(t, hasArchiveTag, "All returned memos should have archive prefix")
}
// Test: Filter archived homelab memos specifically
memos = tc.ListWithFilter(`tags.exists(t, t.startsWith("archive/homelab"))`)
require.Len(t, memos, 1, "Should find only archived homelab memos")
}
// =============================================================================
@ -621,3 +881,48 @@ func TestMemoFilterNoMatches(t *testing.T) {
memos := tc.ListWithFilter(`content.contains("nonexistent12345")`)
require.Len(t, memos, 0)
}
func TestMemoFilterJSONBooleanLogic(t *testing.T) {
t.Parallel()
tc := NewMemoFilterTestContext(t)
defer tc.Close()
// 1. Memo with task list (true) and NO link (null)
tc.CreateMemo(NewMemoBuilder("memo-task-only", tc.User.ID).
Content("Task only").
Property(func(p *storepb.MemoPayload_Property) { p.HasTaskList = true }))
// 2. Memo with link (true) and NO task list (null)
tc.CreateMemo(NewMemoBuilder("memo-link-only", tc.User.ID).
Content("Link only").
Property(func(p *storepb.MemoPayload_Property) { p.HasLink = true }))
// 3. Memo with both (true)
tc.CreateMemo(NewMemoBuilder("memo-both", tc.User.ID).
Content("Both").
Property(func(p *storepb.MemoPayload_Property) {
p.HasTaskList = true
p.HasLink = true
}))
// 4. Memo with neither (null)
tc.CreateMemo(NewMemoBuilder("memo-neither", tc.User.ID).Content("Neither"))
// Test A: has_task_list || has_link
// Expected: 3 memos (task-only, link-only, both). Neither should be excluded.
// This specifically tests the NULL handling in OR logic (NULL || TRUE should be TRUE)
memos := tc.ListWithFilter(`has_task_list || has_link`)
require.Len(t, memos, 3, "Should find 3 memos with OR logic")
// Test B: !has_task_list
// Expected: 2 memos (link-only, neither). Memos where has_task_list is NULL or FALSE.
// Note: If NULL is not handled, !NULL is still NULL (false-y in WHERE), so "neither" might be missed depending on logic.
// In our implementation, we want missing fields to behave as false.
memos = tc.ListWithFilter(`!has_task_list`)
require.Len(t, memos, 2, "Should find 2 memos where task list is false or missing")
// Test C: has_task_list && !has_link
// Expected: 1 memo (task-only).
memos = tc.ListWithFilter(`has_task_list && !has_link`)
require.Len(t, memos, 1, "Should find 1 memo (task only)")
}

View File

@ -2,6 +2,7 @@ package test
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
@ -655,3 +656,220 @@ func TestUserSettingMultipleSettingTypes(t *testing.T) {
ts.Close()
}
func TestUserSettingShortcutsEdgeCases(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Case 1: Special characters in Filter and Title
// Includes quotes, backslashes, newlines, and other JSON-sensitive characters
specialCharsFilter := `tag in ["work", "project"] && content.contains("urgent")`
specialCharsTitle := `Work "Urgent" \ Notes`
shortcuts := &storepb.ShortcutsUserSetting{
Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{
{Id: "s1", Title: specialCharsTitle, Filter: specialCharsFilter},
},
}
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_SHORTCUTS,
Value: &storepb.UserSetting_Shortcuts{Shortcuts: shortcuts},
})
require.NoError(t, err)
setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_SHORTCUTS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Len(t, setting.GetShortcuts().Shortcuts, 1)
require.Equal(t, specialCharsTitle, setting.GetShortcuts().Shortcuts[0].Title)
require.Equal(t, specialCharsFilter, setting.GetShortcuts().Shortcuts[0].Filter)
// Case 2: Unicode characters
unicodeFilter := `tag in ["你好", "世界"]`
unicodeTitle := `My 🚀 Shortcuts`
shortcuts = &storepb.ShortcutsUserSetting{
Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{
{Id: "s2", Title: unicodeTitle, Filter: unicodeFilter},
},
}
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_SHORTCUTS,
Value: &storepb.UserSetting_Shortcuts{Shortcuts: shortcuts},
})
require.NoError(t, err)
setting, err = ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_SHORTCUTS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Len(t, setting.GetShortcuts().Shortcuts, 1)
require.Equal(t, unicodeTitle, setting.GetShortcuts().Shortcuts[0].Title)
require.Equal(t, unicodeFilter, setting.GetShortcuts().Shortcuts[0].Filter)
// Case 3: Empty shortcuts list
// Should allow saving an empty list (clearing shortcuts)
shortcuts = &storepb.ShortcutsUserSetting{
Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{},
}
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_SHORTCUTS,
Value: &storepb.UserSetting_Shortcuts{Shortcuts: shortcuts},
})
require.NoError(t, err)
setting, err = ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_SHORTCUTS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.NotNil(t, setting.GetShortcuts())
require.Len(t, setting.GetShortcuts().Shortcuts, 0)
// Case 4: Large filter string
// Test reasonable large string handling (e.g. 4KB)
largeFilter := strings.Repeat("tag:long_tag_name ", 200)
shortcuts = &storepb.ShortcutsUserSetting{
Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{
{Id: "s3", Title: "Large Filter", Filter: largeFilter},
},
}
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_SHORTCUTS,
Value: &storepb.UserSetting_Shortcuts{Shortcuts: shortcuts},
})
require.NoError(t, err)
setting, err = ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_SHORTCUTS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Equal(t, largeFilter, setting.GetShortcuts().Shortcuts[0].Filter)
ts.Close()
}
func TestUserSettingShortcutsPartialUpdate(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Initial set
shortcuts := &storepb.ShortcutsUserSetting{
Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{
{Id: "s1", Title: "Note 1", Filter: "tag:1"},
{Id: "s2", Title: "Note 2", Filter: "tag:2"},
},
}
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_SHORTCUTS,
Value: &storepb.UserSetting_Shortcuts{Shortcuts: shortcuts},
})
require.NoError(t, err)
// Update by replacing the whole list (Store Upsert replaces the value for the key)
// We want to verify that we can "update" a single item by sending the modified list
updatedShortcuts := &storepb.ShortcutsUserSetting{
Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{
{Id: "s1", Title: "Note 1 Updated", Filter: "tag:1_updated"},
{Id: "s2", Title: "Note 2", Filter: "tag:2"},
{Id: "s3", Title: "Note 3", Filter: "tag:3"}, // Add new one
},
}
_, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: user.ID,
Key: storepb.UserSetting_SHORTCUTS,
Value: &storepb.UserSetting_Shortcuts{Shortcuts: updatedShortcuts},
})
require.NoError(t, err)
setting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &user.ID,
Key: storepb.UserSetting_SHORTCUTS,
})
require.NoError(t, err)
require.NotNil(t, setting)
require.Len(t, setting.GetShortcuts().Shortcuts, 3)
// Verify updates
for _, s := range setting.GetShortcuts().Shortcuts {
if s.Id == "s1" {
require.Equal(t, "Note 1 Updated", s.Title)
require.Equal(t, "tag:1_updated", s.Filter)
} else if s.Id == "s2" {
require.Equal(t, "Note 2", s.Title)
} else if s.Id == "s3" {
require.Equal(t, "Note 3", s.Title)
}
}
ts.Close()
}
func TestUserSettingJSONFieldsEdgeCases(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
// Case 1: Webhook with special characters and Unicode in Title and URL
specialWebhook := &storepb.WebhooksUserSetting_Webhook{
Id: "wh-special",
Title: `My "Special" & <Webhook> 🚀`,
Url: "https://example.com/hook?query=你好&param=\"value\"",
}
err = ts.AddUserWebhook(ctx, user.ID, specialWebhook)
require.NoError(t, err)
webhooks, err := ts.GetUserWebhooks(ctx, user.ID)
require.NoError(t, err)
require.Len(t, webhooks, 1)
require.Equal(t, specialWebhook.Title, webhooks[0].Title)
require.Equal(t, specialWebhook.Url, webhooks[0].Url)
// Case 2: PAT with special description
specialPAT := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{
TokenId: "pat-special",
TokenHash: "hash-special",
Description: "Token for 'CLI' \n & \"API\" \t with unicode 🔑",
}
err = ts.AddUserPersonalAccessToken(ctx, user.ID, specialPAT)
require.NoError(t, err)
pats, err := ts.GetUserPersonalAccessTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, pats, 1)
require.Equal(t, specialPAT.Description, pats[0].Description)
// Case 3: Refresh Token with special description
specialRefreshToken := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: "rt-special",
Description: "Browser: Firefox (Nightly) / OS: Linux 🐧",
}
err = ts.AddUserRefreshToken(ctx, user.ID, specialRefreshToken)
require.NoError(t, err)
tokens, err := ts.GetUserRefreshTokens(ctx, user.ID)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, specialRefreshToken.Description, tokens[0].Description)
ts.Close()
}