package expiry import ( "context" "path/filepath" "testing" "time" "github.com/noderunners/nip05api/internal/audit" "github.com/noderunners/nip05api/internal/db" "github.com/noderunners/nip05api/internal/dm" "github.com/noderunners/nip05api/internal/messages" "github.com/noderunners/nip05api/internal/user" "github.com/noderunners/nip05api/internal/webhook" ) const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" func newTestStack(t *testing.T) (*db.DB, *user.Service, *dm.Service, *webhook.Service, *audit.Logger) { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } if err := d.Migrate(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) tmpls, _ := messages.Load("/nonexistent.yaml") users := user.NewService(user.NewRepo(d), nil) dms := dm.NewService(dm.NewRepo(d), tmpls, true) hooks := webhook.NewService(webhook.NewRepo(d), "test.local", true) return d, users, dms, hooks, audit.New(d) } func TestExpiry_Reminder(t *testing.T) { d, users, dms, hooks, aud := newTestStack(t) now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) expires := now.Add(7 * 24 * time.Hour) u := &user.User{ Pubkey: testHex, Username: "alice", SubscriptionType: user.SubYearly, ExpiresAt: &expires, IsActive: true, } if err := users.Repo().Insert(context.Background(), u); err != nil { t.Fatal(err) } w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", 30, []int{7}, 9).WithClock(func() time.Time { return now }) w.RunOnce(context.Background()) var dmCount int if err := d.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil { t.Fatal(err) } if dmCount != 1 { t.Errorf("expected 1 expiring_soon DM, got %d", dmCount) } // Reminder flag should be set, so a re-run does not double-send. w.RunOnce(context.Background()) if err := d.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil { t.Fatal(err) } if dmCount != 1 { t.Errorf("re-run should not double-fire, got %d", dmCount) } } func TestExpiry_Expiration(t *testing.T) { d, users, dms, hooks, aud := newTestStack(t) now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) expired := now.Add(-1 * time.Hour) u := &user.User{ Pubkey: testHex, Username: "bob", SubscriptionType: user.SubYearly, ExpiresAt: &expired, IsActive: true, } if err := users.Repo().Insert(context.Background(), u); err != nil { t.Fatal(err) } w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", 30, []int{7}, 9).WithClock(func() time.Time { return now }) w.RunOnce(context.Background()) got, err := users.Repo().GetByPubkey(context.Background(), testHex) if err != nil { t.Fatal(err) } if got.IsActive { t.Error("expected user deactivated") } if got.DeactivatedAt == nil { t.Error("expected deactivated_at set") } var dmCount int if err := d.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expired'`).Scan(&dmCount); err != nil { t.Fatal(err) } if dmCount != 1 { t.Errorf("expected 1 expired DM, got %d", dmCount) } var hookCount int if err := d.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM webhook_outbox WHERE event_type = 'user.removed'`).Scan(&hookCount); err != nil { t.Fatal(err) } if hookCount != 1 { t.Errorf("expected 1 user.removed webhook, got %d", hookCount) } } func TestExpiry_GraceCleanup(t *testing.T) { d, users, dms, hooks, aud := newTestStack(t) now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) deactivated := now.Add(-31 * 24 * time.Hour) expired := now.Add(-32 * 24 * time.Hour) u := &user.User{ Pubkey: testHex, Username: "charlie", SubscriptionType: user.SubYearly, ExpiresAt: &expired, IsActive: false, DeactivatedAt: &deactivated, } if err := users.Repo().Insert(context.Background(), u); err != nil { t.Fatal(err) } // Insert sets is_active=1 by default; force it inactive. if err := users.Repo().Update(context.Background(), u); err != nil { t.Fatal(err) } w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", 30, []int{7}, 9).WithClock(func() time.Time { return now }) w.RunOnce(context.Background()) // User should be hard-deleted. _, err := users.Repo().GetByPubkey(context.Background(), testHex) if err == nil { t.Error("expected user deleted after grace cleanup") } // Audit log should record the purge. var auditCount int if err := d.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM audit_log WHERE action = 'user.grace_purged'`).Scan(&auditCount); err != nil { t.Fatal(err) } if auditCount != 1 { t.Errorf("expected 1 grace_purged audit, got %d", auditCount) } } func TestExpiry_LifetimeUserNotAffected(t *testing.T) { d, users, dms, hooks, aud := newTestStack(t) now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) u := &user.User{ Pubkey: testHex, Username: "lifetime", SubscriptionType: user.SubLifetime, IsActive: true, } if err := users.Repo().Insert(context.Background(), u); err != nil { t.Fatal(err) } w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", 30, []int{7}, 9).WithClock(func() time.Time { return now }) w.RunOnce(context.Background()) got, err := users.Repo().GetByPubkey(context.Background(), testHex) if err != nil { t.Fatal(err) } if !got.IsActive { t.Error("lifetime user should not be deactivated") } var dmCount int if err := d.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM dm_outbox`).Scan(&dmCount); err != nil { t.Fatal(err) } if dmCount != 0 { t.Errorf("lifetime user should receive no DM, got %d", dmCount) } }