package store import ( "context" "errors" "net" "testing" "time" "migadu_counters/internal/config" ) func TestMarkerKey(t *testing.T) { got := MarkerKey("A", "x@example.com", "2026-10-13", "o", MarkerLimit) if want := "ntf/A/x@example.com/d/2026-10-13/o/limit"; got != want { t.Errorf("MarkerKey = %q, want %q", got, want) } if got := MarkerKey("O", "7", "2026-10-13", "i", MarkerWarn); got != "ntf/O/7/d/2026-10-13/i/warn" { t.Errorf("MarkerKey = %q", got) } } func TestMarkAndClaimNotified(t *testing.T) { s, w, _ := testStore(t) ctx := context.Background() if err := s.MarkNotified(ctx, "ntf/a"); err != nil { t.Fatal(err) } if ttl := w.TTL("ntf/a"); ttl != MarkerTTL { t.Errorf("TTL = %v, want %v", ttl, MarkerTTL) } // Marking again is not an error: a reset counter may cross again. if err := s.MarkNotified(ctx, "ntf/a"); err != nil { t.Errorf("second mark: %v", err) } ok, err := s.ClaimNotified(ctx, "ntf/a") if err != nil || ok { t.Errorf("claim of a marked key = %v, %v; want false", ok, err) } ok, err = s.ClaimNotified(ctx, "ntf/b") if err != nil || !ok { t.Errorf("first claim = %v, %v; want true", ok, err) } if ttl := w.TTL("ntf/b"); ttl != MarkerTTL { t.Errorf("claim TTL = %v, want %v", ttl, MarkerTTL) } ok, err = s.ClaimNotified(ctx, "ntf/b") if err != nil || ok { t.Errorf("second claim = %v, %v; want false", ok, err) } } func TestClaimNotified_RedisDown(t *testing.T) { s, w, _ := testStore(t) w.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if ok, err := s.ClaimNotified(ctx, "ntf/a"); err == nil || ok { t.Errorf("claim with redis down = %v, %v; want an error and false", ok, err) } } // No connection could be had from the pool: no command was ever sent. func TestOutcomeUnknown_PoolTimeoutIsDefinite(t *testing.T) { if OutcomeUnknown(errors.New("redis incr: redis: connection pool timeout")) { t.Error("a pool timeout never reached Redis and must not be an unknown outcome") } } func TestOutcomeUnknown_NotAppliedIsDefinite(t *testing.T) { if OutcomeUnknown(ErrNotApplied) { t.Error("ErrNotApplied must not be an unknown outcome") } } // Transaction-level rejection: every command failed, nothing was applied. func TestIncr_RejectedTransactionIsNotApplied(t *testing.T) { s, w, _ := testStore(t) w.SetError("READONLY You can't write against a read only replica.") defer w.SetError("") _, err := s.Incr(context.Background(), []string{"a", "b"}) if !errors.Is(err, ErrNotApplied) { t.Fatalf("err = %v, want ErrNotApplied", err) } } // All INCRs failing on non-numbers is still per-key: the EXPIREs succeeded and // EXEC ran, so it must not be mistaken for a rejected transaction. func TestIncr_AllKeysNonNumericIsPerKey(t *testing.T) { s, w, _ := testStore(t) w.Set("a", "x") w.Set("b", "y") res, err := s.Incr(context.Background(), []string{"a", "b"}) if err != nil { t.Fatalf("per-key failures must not fail the transaction: %v", err) } if res[0].Err == nil || res[1].Err == nil { t.Errorf("results = %+v", res) } } // A context deadline must bound socket time, not only the configured timeout. func TestPing_HonoursContextDeadline(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer ln.Close() go func() { // accepts and never answers for { c, err := ln.Accept() if err != nil { return } defer c.Close() } }() s, err := New(config.RedisConfig{WriteAddr: ln.Addr().String(), PoolSize: 2, TimeoutMS: 3000}) if err != nil { t.Fatal(err) } defer s.Close() ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() start := time.Now() if err := s.Ping(ctx); err == nil { t.Fatal("expected a timeout") } if d := time.Since(start); d > time.Second { t.Errorf("Ping took %v with a 100ms deadline", d) } }