package recovery_test import ( "context" "errors" "path/filepath" "testing" "time" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/recovery" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" ) func newStore(t *testing.T, opts ...recovery.Option) (*recovery.Store, *store.DB, int64) { t.Helper() ctx := context.Background() db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := db.Migrate(); err != nil { t.Fatalf("Migrate: %v", err) } aud := audit.New(db.DB) us := users.New(db.DB, aud) admin, err := us.Create(ctx, 0, "admin@example.com", "A", users.RoleAdmin) if err != nil { t.Fatalf("create admin: %v", err) } return recovery.NewStore(db.DB, aud, opts...), db, admin } // TestCreateIsIdempotent: a second create reuses the live pending request and // reports created=false (so the chain is not notified twice). func TestCreateIsIdempotent(t *testing.T) { ctx := context.Background() s, _, uid := newStore(t) first, created, err := s.Create(ctx, uid, "127.0.0.1") if err != nil || !created { t.Fatalf("first Create = (%+v, %v, %v), want created", first, created, err) } again, created, err := s.Create(ctx, uid, "127.0.0.1") if err != nil { t.Fatalf("second Create: %v", err) } if created { t.Error("a second Create reported created=true, want a reuse") } if again.ID != first.ID { t.Errorf("reuse returned request %d, want %d", again.ID, first.ID) } } // TestCapIsEnforced: once a user is at the cap, Create refuses with // ErrTooManyPending (SPEC-007 ยง4: "capped number per user"). func TestCapIsEnforced(t *testing.T) { ctx := context.Background() s, _, uid := newStore(t, recovery.WithCap(1)) if _, created, err := s.Create(ctx, uid, "127.0.0.1"); err != nil || !created { t.Fatalf("first Create = (created=%v, %v), want a new request", created, err) } if _, _, err := s.Create(ctx, uid, "127.0.0.1"); !errors.Is(err, recovery.ErrTooManyPending) { t.Errorf("Create at the cap = %v, want ErrTooManyPending", err) } } // TestExpireTombstonesStaleAndFreesTheUser: a request past the TTL is expired // and the user may open a fresh one. func TestExpireTombstonesStaleAndFreesTheUser(t *testing.T) { ctx := context.Background() now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) clock := now s, db, uid := newStore(t, recovery.WithTTL(72*time.Hour), recovery.WithClock(func() time.Time { return clock })) first, _, err := s.Create(ctx, uid, "127.0.0.1") if err != nil { t.Fatalf("Create: %v", err) } // Advance well past the TTL and open another: the stale row is expired and // a NEW request is created. clock = now.Add(100 * time.Hour) second, created, err := s.Create(ctx, uid, "127.0.0.1") if err != nil || !created { t.Fatalf("Create after expiry = (%+v, %v, %v), want a new request", second, created, err) } if second.ID == first.ID { t.Error("the stale request was reused instead of expired") } stale, err := s.Get(ctx, first.ID) if err != nil { t.Fatalf("Get(first): %v", err) } if stale.Status != recovery.StatusExpired { t.Errorf("stale request status = %s, want expired", stale.Status) } // The expiry is audited (A11). var n int if err := db.QueryRow(`SELECT count(*) FROM audit_log WHERE action = 'recovery.expire'`).Scan(&n); err != nil { t.Fatalf("read audit_log: %v", err) } if n == 0 { t.Error("the expiry was not audited") } }