diff --git a/go.mod b/go.mod index 26bee77..0313b98 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/codeGROOVE-dev/fido go 1.25.4 -require github.com/puzpuzpuz/xsync/v4 v4.2.0 +require github.com/puzpuzpuz/xsync/v4 v4.3.0 diff --git a/go.sum b/go.sum index 95aef44..685082e 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0= -github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +github.com/puzpuzpuz/xsync/v4 v4.3.0 h1:w/bWkEJdYuRNYhHn5eXnIT8LzDM1O629X1I9MJSkD7Q= +github.com/puzpuzpuz/xsync/v4 v4.3.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= diff --git a/pkg/store/cloudrun/go.mod b/pkg/store/cloudrun/go.mod index 94aa173..31ecfb2 100644 --- a/pkg/store/cloudrun/go.mod +++ b/pkg/store/cloudrun/go.mod @@ -9,8 +9,8 @@ require ( ) require ( - github.com/codeGROOVE-dev/ds9 v0.8.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/codeGROOVE-dev/ds9 v0.8.1 // indirect + github.com/klauspost/compress v1.18.3 // indirect ) replace github.com/codeGROOVE-dev/fido/pkg/store/datastore => ../datastore diff --git a/pkg/store/cloudrun/go.sum b/pkg/store/cloudrun/go.sum index 68d7396..1388405 100644 --- a/pkg/store/cloudrun/go.sum +++ b/pkg/store/cloudrun/go.sum @@ -1,6 +1,6 @@ -github.com/codeGROOVE-dev/ds9 v0.8.0 h1:A23VvL1YzUBZyXNYmF5u0R6nPcxQitPeLo8FFk6OiUs= -github.com/codeGROOVE-dev/ds9 v0.8.0/go.mod h1:0UDipxF1DADfqM5GtjefgB2u+EXdDgOKmxVvrSGLHoM= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/codeGROOVE-dev/ds9 v0.8.1 h1:jXSCoKe6iSjhgdbN1XFkMd1reE0yFWI4fpH5QHtrE4Y= +github.com/codeGROOVE-dev/ds9 v0.8.1/go.mod h1:0UDipxF1DADfqM5GtjefgB2u+EXdDgOKmxVvrSGLHoM= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= diff --git a/pkg/store/compress/go.mod b/pkg/store/compress/go.mod index 2263e78..f8a82a9 100644 --- a/pkg/store/compress/go.mod +++ b/pkg/store/compress/go.mod @@ -2,4 +2,4 @@ module github.com/codeGROOVE-dev/fido/pkg/store/compress go 1.25.4 -require github.com/klauspost/compress v1.18.2 +require github.com/klauspost/compress v1.18.3 diff --git a/pkg/store/compress/go.sum b/pkg/store/compress/go.sum index 0190a64..6511e4b 100644 --- a/pkg/store/compress/go.sum +++ b/pkg/store/compress/go.sum @@ -1,2 +1,2 @@ -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= diff --git a/pkg/store/datastore/go.mod b/pkg/store/datastore/go.mod index a656d47..824a001 100644 --- a/pkg/store/datastore/go.mod +++ b/pkg/store/datastore/go.mod @@ -3,10 +3,10 @@ module github.com/codeGROOVE-dev/fido/pkg/store/datastore go 1.25.4 require ( - github.com/codeGROOVE-dev/ds9 v0.8.0 + github.com/codeGROOVE-dev/ds9 v0.8.1 github.com/codeGROOVE-dev/fido/pkg/store/compress v1.10.0 ) -require github.com/klauspost/compress v1.18.2 // indirect +require github.com/klauspost/compress v1.18.3 // indirect replace github.com/codeGROOVE-dev/fido/pkg/store/compress => ../compress diff --git a/pkg/store/datastore/go.sum b/pkg/store/datastore/go.sum index 539a4c7..bf1f635 100644 --- a/pkg/store/datastore/go.sum +++ b/pkg/store/datastore/go.sum @@ -1,4 +1,4 @@ -github.com/codeGROOVE-dev/ds9 v0.8.0 h1:A23VvL1YzUBZyXNYmF5u0R6nPcxQitPeLo8FFk6OiUs= -github.com/codeGROOVE-dev/ds9 v0.8.0/go.mod h1:0UDipxF1DADfqM5GtjefgB2u+EXdDgOKmxVvrSGLHoM= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/codeGROOVE-dev/ds9 v0.8.1 h1:jXSCoKe6iSjhgdbN1XFkMd1reE0yFWI4fpH5QHtrE4Y= +github.com/codeGROOVE-dev/ds9 v0.8.1/go.mod h1:0UDipxF1DADfqM5GtjefgB2u+EXdDgOKmxVvrSGLHoM= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= diff --git a/pkg/store/datastore/persist_datastore_test.go b/pkg/store/datastore/persist_datastore_test.go index 742c9b3..a22bd72 100644 --- a/pkg/store/datastore/persist_datastore_test.go +++ b/pkg/store/datastore/persist_datastore_test.go @@ -2,6 +2,7 @@ package datastore import ( "context" + "maps" "os" "testing" "time" @@ -328,3 +329,186 @@ func TestDatastorePersist_CleanupEmpty(t *testing.T) { t.Logf("Cleanup count = %d (found existing expired entries)", count) } } + +func TestDatastorePersist_Keys(t *testing.T) { + ctx := context.Background() + dp, cleanup := createTestStore[string, string](t, ctx) + defer cleanup() + + // Set entries with different prefixes + entries := map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + "post:1": "post-1-data", + "post:2": "post-2-data", + "other": "other-data", + } + + for k, v := range entries { + if err := dp.Set(ctx, k, v, time.Time{}); err != nil { + t.Fatalf("Set %s: %v", k, err) + } + } + + tests := []struct { + name string + prefix string + want []string + }{ + {"user prefix", "user:", []string{"user:alice", "user:bob", "user:charlie"}}, + {"post prefix", "post:", []string{"post:1", "post:2"}}, + {"other prefix", "other", []string{"other"}}, + {"no match", "nomatch:", []string{}}, + {"empty prefix", "", []string{"other", "post:1", "post:2", "user:alice", "user:bob", "user:charlie"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var keys []string + for k := range dp.Keys(ctx, tt.prefix) { + keys = append(keys, k) + } + + if len(keys) != len(tt.want) { + t.Errorf("Keys() returned %d keys; want %d. Got: %v, Want: %v", len(keys), len(tt.want), keys, tt.want) + return + } + + // Create map for comparison + keyMap := make(map[string]bool) + for _, k := range keys { + keyMap[k] = true + } + + for _, wantKey := range tt.want { + if !keyMap[wantKey] { + t.Errorf("Keys() missing key %q", wantKey) + } + } + }) + } + + // Cleanup all test entries + for k := range entries { + if err := dp.Delete(ctx, k); err != nil { + t.Logf("Delete error: %v", err) + } + } +} + +func TestDatastorePersist_Range(t *testing.T) { + ctx := context.Background() + dp, cleanup := createTestStore[string, string](t, ctx) + defer cleanup() + + // Set entries with different prefixes + entries := map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + "post:1": "post-1-data", + "post:2": "post-2-data", + "other": "other-data", + } + + for k, v := range entries { + if err := dp.Set(ctx, k, v, time.Time{}); err != nil { + t.Fatalf("Set %s: %v", k, err) + } + } + + tests := []struct { + name string + prefix string + want map[string]string + }{ + {"user prefix", "user:", map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + }}, + {"post prefix", "post:", map[string]string{ + "post:1": "post-1-data", + "post:2": "post-2-data", + }}, + {"other prefix", "other", map[string]string{ + "other": "other-data", + }}, + {"no match", "nomatch:", map[string]string{}}, + {"empty prefix", "", entries}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maps.Collect(dp.Range(ctx, tt.prefix)) + + if len(result) != len(tt.want) { + t.Errorf("Range() returned %d entries; want %d", len(result), len(tt.want)) + return + } + + for k, wantVal := range tt.want { + gotVal, found := result[k] + if !found { + t.Errorf("Range() missing key %q", k) + continue + } + if gotVal != wantVal { + t.Errorf("Range() key %q = %q; want %q", k, gotVal, wantVal) + } + } + }) + } + + // Cleanup all test entries + for k := range entries { + if err := dp.Delete(ctx, k); err != nil { + t.Logf("Delete error: %v", err) + } + } +} + +func TestDatastorePersist_Range_SkipsExpired(t *testing.T) { + ctx := context.Background() + dp, cleanup := createTestStore[string, string](t, ctx) + defer cleanup() + + // Set entries with different expiry times + past := time.Now().Add(-1 * time.Hour) + future := time.Now().Add(1 * time.Hour) + + if err := dp.Set(ctx, "expired-1", "value1", past); err != nil { + t.Fatalf("Set: %v", err) + } + if err := dp.Set(ctx, "valid-1", "value2", future); err != nil { + t.Fatalf("Set: %v", err) + } + if err := dp.Set(ctx, "valid-2", "value3", time.Time{}); err != nil { + t.Fatalf("Set: %v", err) + } + + // Range should only return valid entries + result := maps.Collect(dp.Range(ctx, "")) + + if len(result) != 2 { + t.Errorf("Range() returned %d entries; want 2 (expired entries should be skipped)", len(result)) + } + + if _, found := result["expired-1"]; found { + t.Error("Range() should skip expired entries") + } + + if val, found := result["valid-1"]; !found || val != "value2" { + t.Error("Range() should return valid-1") + } + + if val, found := result["valid-2"]; !found || val != "value3" { + t.Error("Range() should return valid-2") + } + + // Cleanup + _ = dp.Delete(ctx, "valid-1") //nolint:errcheck // test cleanup + _ = dp.Delete(ctx, "valid-2") //nolint:errcheck // test cleanup + _ = dp.Delete(ctx, "expired-1") //nolint:errcheck // test cleanup +} diff --git a/pkg/store/localfs/go.mod b/pkg/store/localfs/go.mod index 11ae786..8255f1d 100644 --- a/pkg/store/localfs/go.mod +++ b/pkg/store/localfs/go.mod @@ -4,7 +4,7 @@ go 1.25.4 require ( github.com/codeGROOVE-dev/fido/pkg/store/compress v1.10.0 - github.com/klauspost/compress v1.18.2 + github.com/klauspost/compress v1.18.3 github.com/pierrec/lz4/v4 v4.1.22 ) diff --git a/pkg/store/localfs/go.sum b/pkg/store/localfs/go.sum index 14175b4..8c2bb2f 100644 --- a/pkg/store/localfs/go.sum +++ b/pkg/store/localfs/go.sum @@ -1,4 +1,4 @@ -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= diff --git a/pkg/store/localfs/integration_test.go b/pkg/store/localfs/integration_test.go index 89e2417..983b95f 100644 --- a/pkg/store/localfs/integration_test.go +++ b/pkg/store/localfs/integration_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "os" "path/filepath" "strings" @@ -1209,6 +1210,232 @@ func TestFilePersist_Compression_IsolatedNamespaces(t *testing.T) { } } +func TestFilePersist_Keys(t *testing.T) { + dir := t.TempDir() + fp, err := New[string, string]("test", dir) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := fp.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + ctx := context.Background() + + // Set entries with different prefixes + entries := map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + "post:1": "post-1-data", + "post:2": "post-2-data", + "other": "other-data", + } + + for k, v := range entries { + if err := fp.Set(ctx, k, v, time.Time{}); err != nil { + t.Fatalf("Set %s: %v", k, err) + } + } + + tests := []struct { + name string + prefix string + want []string + }{ + {"user prefix", "user:", []string{"user:alice", "user:bob", "user:charlie"}}, + {"post prefix", "post:", []string{"post:1", "post:2"}}, + {"other prefix", "other", []string{"other"}}, + {"no match", "nomatch:", []string{}}, + {"empty prefix", "", []string{"other", "post:1", "post:2", "user:alice", "user:bob", "user:charlie"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var keys []string + for k := range fp.Keys(ctx, tt.prefix) { + keys = append(keys, k) + } + + if len(keys) != len(tt.want) { + t.Errorf("Keys() returned %d keys; want %d. Got: %v, Want: %v", len(keys), len(tt.want), keys, tt.want) + return + } + + // Sort for comparison + keyMap := make(map[string]bool) + for _, k := range keys { + keyMap[k] = true + } + + for _, wantKey := range tt.want { + if !keyMap[wantKey] { + t.Errorf("Keys() missing key %q", wantKey) + } + } + }) + } +} + +func TestFilePersist_Range(t *testing.T) { + dir := t.TempDir() + fp, err := New[string, string]("test", dir) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := fp.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + ctx := context.Background() + + // Set entries with different prefixes + entries := map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + "post:1": "post-1-data", + "post:2": "post-2-data", + "other": "other-data", + } + + for k, v := range entries { + if err := fp.Set(ctx, k, v, time.Time{}); err != nil { + t.Fatalf("Set %s: %v", k, err) + } + } + + tests := []struct { + name string + prefix string + want map[string]string + }{ + {"user prefix", "user:", map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + }}, + {"post prefix", "post:", map[string]string{ + "post:1": "post-1-data", + "post:2": "post-2-data", + }}, + {"other prefix", "other", map[string]string{ + "other": "other-data", + }}, + {"no match", "nomatch:", map[string]string{}}, + {"empty prefix", "", entries}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maps.Collect(fp.Range(ctx, tt.prefix)) + + if len(result) != len(tt.want) { + t.Errorf("Range() returned %d entries; want %d", len(result), len(tt.want)) + return + } + + for k, wantVal := range tt.want { + gotVal, found := result[k] + if !found { + t.Errorf("Range() missing key %q", k) + continue + } + if gotVal != wantVal { + t.Errorf("Range() key %q = %q; want %q", k, gotVal, wantVal) + } + } + }) + } +} + +func TestFilePersist_Range_SkipsExpired(t *testing.T) { + dir := t.TempDir() + fp, err := New[string, string]("test", dir) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := fp.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + ctx := context.Background() + + // Set entries with different expiry times + past := time.Now().Add(-1 * time.Hour) + future := time.Now().Add(1 * time.Hour) + + if err := fp.Set(ctx, "expired-1", "value1", past); err != nil { + t.Fatalf("Set: %v", err) + } + if err := fp.Set(ctx, "valid-1", "value2", future); err != nil { + t.Fatalf("Set: %v", err) + } + if err := fp.Set(ctx, "valid-2", "value3", time.Time{}); err != nil { + t.Fatalf("Set: %v", err) + } + + // Range should only return valid entries + result := maps.Collect(fp.Range(ctx, "")) + + if len(result) != 2 { + t.Errorf("Range() returned %d entries; want 2 (expired entries should be skipped)", len(result)) + } + + if _, found := result["expired-1"]; found { + t.Error("Range() should skip expired entries") + } + + if val, found := result["valid-1"]; !found || val != "value2" { + t.Error("Range() should return valid-1") + } + + if val, found := result["valid-2"]; !found || val != "value3" { + t.Error("Range() should return valid-2") + } +} + +func TestFilePersist_Keys_ContextCancellation(t *testing.T) { + dir := t.TempDir() + fp, err := New[string, int]("test", dir) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := fp.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + // Set many entries + for i := range 100 { + if err := fp.Set(context.Background(), fmt.Sprintf("key-%d", i), i, time.Time{}); err != nil { + t.Fatalf("Set: %v", err) + } + } + + // Create context that we'll cancel + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Keys should respect context cancellation + count := 0 + for range fp.Keys(ctx, "") { + count++ + } + + // We should have stopped early due to cancellation + if count == 100 { + t.Error("Keys() should have stopped due to context cancellation") + } +} + func TestFilePersist_Compression_CleanupRespectExtension(t *testing.T) { dir := t.TempDir() ctx := context.Background() diff --git a/pkg/store/null/go.mod b/pkg/store/null/go.mod index 0800b44..63a5cab 100644 --- a/pkg/store/null/go.mod +++ b/pkg/store/null/go.mod @@ -4,6 +4,6 @@ go 1.25.4 require github.com/codeGROOVE-dev/fido/pkg/store/compress v1.10.0 -require github.com/klauspost/compress v1.18.2 // indirect +require github.com/klauspost/compress v1.18.3 // indirect replace github.com/codeGROOVE-dev/fido/pkg/store/compress => ../compress diff --git a/pkg/store/null/go.sum b/pkg/store/null/go.sum index 0190a64..6511e4b 100644 --- a/pkg/store/null/go.sum +++ b/pkg/store/null/go.sum @@ -1,2 +1,2 @@ -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= diff --git a/pkg/store/valkey/go.mod b/pkg/store/valkey/go.mod index b33bf3b..91bfb7d 100644 --- a/pkg/store/valkey/go.mod +++ b/pkg/store/valkey/go.mod @@ -8,8 +8,8 @@ require ( ) require ( - github.com/klauspost/compress v1.18.2 // indirect - golang.org/x/sys v0.39.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect + golang.org/x/sys v0.40.0 // indirect ) replace github.com/codeGROOVE-dev/fido/pkg/store/compress => ../compress diff --git a/pkg/store/valkey/go.sum b/pkg/store/valkey/go.sum index a5ec367..fcc6529 100644 --- a/pkg/store/valkey/go.sum +++ b/pkg/store/valkey/go.sum @@ -1,7 +1,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/valkey-io/valkey-go v1.0.70 h1:mjYNT8qiazxDAJ0QNQ8twWT/YFOkOoRd40ERV2mB49Y= @@ -10,7 +10,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/pkg/store/valkey/valkey_test.go b/pkg/store/valkey/valkey_test.go index 4ec802f..4e774ba 100644 --- a/pkg/store/valkey/valkey_test.go +++ b/pkg/store/valkey/valkey_test.go @@ -3,6 +3,7 @@ package valkey import ( "context" "fmt" + "maps" "os" "testing" "time" @@ -795,3 +796,278 @@ func TestValkeyPersist_FlushEmpty(t *testing.T) { t.Errorf("Flush deleted %d entries from empty cache; want 0", deleted) } } + +func TestValkeyPersist_Keys(t *testing.T) { + skipIfNoValkey(t) + + ctx := context.Background() + addr := os.Getenv("VALKEY_ADDR") + if addr == "" { + addr = "localhost:6379" + } + + p, err := New[string, string](ctx, "test-cache-keys", addr) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := p.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + // Set entries with different prefixes + entries := map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + "post:1": "post-1-data", + "post:2": "post-2-data", + "other": "other-data", + } + + for k, v := range entries { + if err := p.Set(ctx, k, v, time.Time{}); err != nil { + t.Fatalf("Set %s: %v", k, err) + } + } + + tests := []struct { + name string + prefix string + want []string + }{ + {"user prefix", "user:", []string{"user:alice", "user:bob", "user:charlie"}}, + {"post prefix", "post:", []string{"post:1", "post:2"}}, + {"other prefix", "other", []string{"other"}}, + {"no match", "nomatch:", []string{}}, + {"empty prefix", "", []string{"other", "post:1", "post:2", "user:alice", "user:bob", "user:charlie"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var keys []string + for k := range p.Keys(ctx, tt.prefix) { + keys = append(keys, k) + } + + if len(keys) != len(tt.want) { + t.Errorf("Keys() returned %d keys; want %d. Got: %v, Want: %v", len(keys), len(tt.want), keys, tt.want) + return + } + + // Create map for comparison + keyMap := make(map[string]bool) + for _, k := range keys { + keyMap[k] = true + } + + for _, wantKey := range tt.want { + if !keyMap[wantKey] { + t.Errorf("Keys() missing key %q", wantKey) + } + } + }) + } + + // Cleanup all test entries + for k := range entries { + if err := p.Delete(ctx, k); err != nil { + t.Logf("Delete error: %v", err) + } + } +} + +func TestValkeyPersist_Range(t *testing.T) { + skipIfNoValkey(t) + + ctx := context.Background() + addr := os.Getenv("VALKEY_ADDR") + if addr == "" { + addr = "localhost:6379" + } + + p, err := New[string, string](ctx, "test-cache-range", addr) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := p.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + // Set entries with different prefixes + entries := map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + "post:1": "post-1-data", + "post:2": "post-2-data", + "other": "other-data", + } + + for k, v := range entries { + if err := p.Set(ctx, k, v, time.Time{}); err != nil { + t.Fatalf("Set %s: %v", k, err) + } + } + + tests := []struct { + name string + prefix string + want map[string]string + }{ + {"user prefix", "user:", map[string]string{ + "user:alice": "alice-data", + "user:bob": "bob-data", + "user:charlie": "charlie-data", + }}, + {"post prefix", "post:", map[string]string{ + "post:1": "post-1-data", + "post:2": "post-2-data", + }}, + {"other prefix", "other", map[string]string{ + "other": "other-data", + }}, + {"no match", "nomatch:", map[string]string{}}, + {"empty prefix", "", entries}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maps.Collect(p.Range(ctx, tt.prefix)) + + if len(result) != len(tt.want) { + t.Errorf("Range() returned %d entries; want %d", len(result), len(tt.want)) + return + } + + for k, wantVal := range tt.want { + gotVal, found := result[k] + if !found { + t.Errorf("Range() missing key %q", k) + continue + } + if gotVal != wantVal { + t.Errorf("Range() key %q = %q; want %q", k, gotVal, wantVal) + } + } + }) + } + + // Cleanup all test entries + for k := range entries { + if err := p.Delete(ctx, k); err != nil { + t.Logf("Delete error: %v", err) + } + } +} + +func TestValkeyPersist_Range_SkipsExpired(t *testing.T) { + skipIfNoValkey(t) + + ctx := context.Background() + addr := os.Getenv("VALKEY_ADDR") + if addr == "" { + addr = "localhost:6379" + } + + p, err := New[string, string](ctx, "test-cache-range-expired", addr) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := p.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + // Set entries with different expiry times + shortExpiry := time.Now().Add(2 * time.Second) + longExpiry := time.Now().Add(1 * time.Hour) + + if err := p.Set(ctx, "expires-soon", "value1", shortExpiry); err != nil { + t.Fatalf("Set: %v", err) + } + if err := p.Set(ctx, "valid-1", "value2", longExpiry); err != nil { + t.Fatalf("Set: %v", err) + } + if err := p.Set(ctx, "valid-2", "value3", time.Time{}); err != nil { + t.Fatalf("Set: %v", err) + } + + // Wait for short expiry to pass + time.Sleep(3 * time.Second) + + // Range should only return valid entries + result := maps.Collect(p.Range(ctx, "")) + + if len(result) != 2 { + t.Errorf("Range() returned %d entries; want 2 (expired entries should be skipped)", len(result)) + } + + if _, found := result["expires-soon"]; found { + t.Error("Range() should skip expired entries") + } + + if val, found := result["valid-1"]; !found || val != "value2" { + t.Error("Range() should return valid-1") + } + + if val, found := result["valid-2"]; !found || val != "value3" { + t.Error("Range() should return valid-2") + } + + // Cleanup + _ = p.Delete(ctx, "valid-1") //nolint:errcheck // test cleanup + _ = p.Delete(ctx, "valid-2") //nolint:errcheck // test cleanup + _ = p.Delete(ctx, "expires-soon") //nolint:errcheck // test cleanup +} + +func TestValkeyPersist_Keys_ContextCancellation(t *testing.T) { + skipIfNoValkey(t) + + ctx := context.Background() + addr := os.Getenv("VALKEY_ADDR") + if addr == "" { + addr = "localhost:6379" + } + + p, err := New[string, int](ctx, "test-cache-keys-cancel", addr) + if err != nil { + t.Fatalf("New: %v", err) + } + defer func() { + if err := p.Close(); err != nil { + t.Logf("Close error: %v", err) + } + }() + + // Set many entries + for i := range 100 { + if err := p.Set(ctx, fmt.Sprintf("key-%d", i), i, time.Time{}); err != nil { + t.Fatalf("Set: %v", err) + } + } + + // Create context that we'll cancel + ctxCancel, cancel := context.WithCancel(context.Background()) + cancel() + + // Keys should respect context cancellation + count := 0 + for range p.Keys(ctxCancel, "") { + count++ + } + + // We should have stopped early due to cancellation + if count == 100 { + t.Error("Keys() should have stopped due to context cancellation") + } + + // Cleanup + for i := range 100 { + _ = p.Delete(ctx, fmt.Sprintf("key-%d", i)) //nolint:errcheck // test cleanup + } +}