diff --git a/plugin/filter/render.go b/plugin/filter/render.go index b1e8b1c99..c00de417e 100644 --- a/plugin/filter/render.go +++ b/plugin/filter/render.go @@ -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: diff --git a/store/test/instance_setting_test.go b/store/test/instance_setting_test.go index 0b99c6bf2..12a2694a7 100644 --- a/store/test/instance_setting_test.go +++ b/store/test/instance_setting_test.go @@ -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 := `` + 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() + } + + diff --git a/store/test/memo_filter_comprehension_test.go b/store/test/memo_filter_comprehension_test.go deleted file mode 100644 index 1ef2211b8..000000000 --- a/store/test/memo_filter_comprehension_test.go +++ /dev/null @@ -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") -} diff --git a/store/test/memo_filter_test.go b/store/test/memo_filter_test.go index 3f5f78f13..572086e79 100644 --- a/store/test/memo_filter_test.go +++ b/store/test/memo_filter_test.go @@ -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)") +} diff --git a/store/test/user_setting_test.go b/store/test/user_setting_test.go index dfaab1d4c..cf66afa22 100644 --- a/store/test/user_setting_test.go +++ b/store/test/user_setting_test.go @@ -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" & πŸš€`, + Url: "https://example.com/hook?query=δ½ ε₯½¶m=\"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() +}