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()
+}