From 60ca62d9796e3ab239a0ad3d314fe66ee8e39efe Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 4 Jun 2026 21:31:55 +0530 Subject: [PATCH] =?UTF-8?q?test(api):=20integration=20coverage=20=E2=80=94?= =?UTF-8?q?=20brevo=20ledger=20webhook=20+=20status=20endpoint=20(toward?= =?UTF-8?q?=20100%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds REAL-DB integration tests for two handlers that previously had ONLY sqlmock (unit) coverage, closing integration gaps flagged in INTEGRATION-COVERAGE-PLAN-2026-06-04 §2.1. brevo_webhook_realdb_integration_test.go — POST /webhooks/brevo/:secret, the rule-12 email truth surface. Seeds real forwarder_sent rows, POSTs synthetic Brevo events, reads back via the production LookupForwarderSentByProviderID helper. Proves the SQL is CORRECT (not just "issued"): delivered overwrites classification + stamps delivered_at; each non-delivered event maps to its terminal class leaving delivered_at NULL; the bug-bash #6 terminal-class guard preserves bounced_hard/soft/ rejected/complaint/unsubscribed against a LATE out-of-order 'delivered' while still upgrading deferred/success; GREATEST delivered_at is monotonic on Brevo retries (idempotent); an orphan messageId is a 200 no-op that creates no row. Testable today despite the unvalidated-Brevo-sender block (synthetic payload, not a live send). status_realdb_integration_test.go — GET /api/v1/status. Seeds real service_components + uptime_samples and asserts the computed payload through a Fiber app on real DB + real Redis: operational (all-healthy) vs down (recent unhealthy probe), empty-DB clean state (never 500), the 60s cache.GetOrSet round-trip (second request served from Redis, cache-bust surfaces a mid-flight DB mutation), and the nil-Redis DB-fallback path. No production code changed. Integration coverage (handlers, -coverpkg=./...): 78.6% -> 78.7%; status.go computeOne 86.0% -> 89.5%. The pre-existing local TestQueue_CredIssueError_FallsBackToLegacyOpen 503 (NATS unreachable locally) is unchanged and unrelated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../brevo_webhook_realdb_integration_test.go | 298 +++++++++++++++++ .../status_realdb_integration_test.go | 305 ++++++++++++++++++ 2 files changed, 603 insertions(+) create mode 100644 internal/handlers/brevo_webhook_realdb_integration_test.go create mode 100644 internal/handlers/status_realdb_integration_test.go diff --git a/internal/handlers/brevo_webhook_realdb_integration_test.go b/internal/handlers/brevo_webhook_realdb_integration_test.go new file mode 100644 index 0000000..81ce9c6 --- /dev/null +++ b/internal/handlers/brevo_webhook_realdb_integration_test.go @@ -0,0 +1,298 @@ +package handlers_test + +// brevo_webhook_realdb_integration_test.go — REAL-DB integration coverage for +// the Brevo transactional-delivery receiver at POST /webhooks/brevo/:secret. +// +// WHY THIS FILE EXISTS (integration-coverage wave 2, 2026-06-04): +// +// brevo_webhook_test.go drives the same handler entirely through sqlmock — it +// asserts the handler ISSUES the right SQL string, but it never proves the SQL +// is CORRECT against a real Postgres schema. The rule-12 email truth surface +// (forwarder_sent.classification + delivered_at) is only as trustworthy as the +// actual UPDATE behaviour: +// +// * the delivered path uses COALESCE(GREATEST(delivered_at, NOW()), NOW()) +// — a sqlmock can't tell us whether that expression even parses against +// the real column type, let alone that it's monotonic. +// * the bug-bash #6 terminal-class guard +// (classification NOT IN (bounced_hard, bounced_soft, rejected, complaint, +// unsubscribed)) is a row-state predicate — sqlmock returns whatever rows +// we tell it to; only a real row can prove a late 'delivered' does NOT +// clobber a recorded 'bounced_hard'. +// +// These tests seed a real forwarder_sent row, POST a synthetic Brevo event +// (the same shape Brevo emits — NOT a live send, so the unvalidated-sender +// production block is irrelevant), and read the row back via the production +// LookupForwarderSentByProviderID helper to assert the ledger overwrite. +// +// Tests are skipped (not failed) when TEST_DATABASE_URL is unreachable so the +// hermetic `-short` gate stays green; CI supplies the DB and runs them for +// real. + +import ( + "context" + "database/sql" + "errors" + "net/http" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/testhelpers" +) + +// brevoTxRealApp builds a Fiber app with ONLY the transactional-delivery +// receiver mounted, wired to the supplied real *sql.DB. Mirrors the production +// ErrorHandler short-circuit so respondError envelopes pass through unchanged. +func brevoTxRealApp(t *testing.T, db *sql.DB) *fiber.App { + t.Helper() + h := handlers.NewBrevoTransactionalWebhookHandler(db, &config.Config{BrevoWebhookSecret: testBrevoTxSecret}) + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + return fiber.DefaultErrorHandler(c, err) + }, + }) + app.Post("/webhooks/brevo/:secret", h.Receive) + return app +} + +// seedForwarderSent inserts a forwarder_sent row with the given provider_id + +// initial classification and returns the audit_id. The row carries +// provider='brevo' so the receiver's (provider, provider_id) lookup matches. +func seedForwarderSent(t *testing.T, db *sql.DB, providerID, classification string) string { + t.Helper() + auditID := uuid.NewString() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO forwarder_sent (audit_id, provider, provider_id, recipient, template_kind, classification) + VALUES ($1, 'brevo', $2, 'u***@example.com', 'anon.expiry_warning', $3) + `, auditID, providerID, classification) + if err != nil { + t.Fatalf("seedForwarderSent: %v", err) + } + return auditID +} + +// ── 1. delivered event overwrites classification AND stamps delivered_at ── + +func TestBrevoTxWebhook_RealDB_DeliveredOverwritesLedger(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + providerID := "msg-realdb-" + uuid.NewString()[:8] + seedForwarderSent(t, db, providerID, "success") // worker's API-acceptance state + + app := brevoTxRealApp(t, db) + body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `","subject":"Welcome"}` + resp := postBrevoTx(t, app, testBrevoTxSecret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + + row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID) + if err != nil { + t.Fatalf("LookupForwarderSentByProviderID: %v", err) + } + if row.Classification != "delivered" { + t.Errorf("classification = %q; want delivered (rule-12 truth surface must overwrite the worker's 'success')", row.Classification) + } + if row.DeliveredAt == nil { + t.Error("delivered_at must be stamped on a 'delivered' event; got nil") + } +} + +// ── 2. each non-delivered event overwrites classification, leaves delivered_at NULL ── + +func TestBrevoTxWebhook_RealDB_FailureEventsOverwriteClassification(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + cases := []struct { + event string + wantClass string + }{ + {"hard_bounce", "bounced_hard"}, + {"soft_bounce", "bounced_soft"}, + {"blocked", "rejected"}, + {"complaint", "complaint"}, + {"spam", "complaint"}, // alias → complaint + {"deferred", "deferred"}, + {"unsubscribed", "unsubscribed"}, + {"error", "error"}, + } + app := brevoTxRealApp(t, db) + for _, c := range cases { + t.Run(c.event, func(t *testing.T) { + providerID := "msg-fail-" + c.event + "-" + uuid.NewString()[:8] + seedForwarderSent(t, db, providerID, "success") + + body := `{"event":"` + c.event + `","email":"u@example.com","message-id":"` + providerID + `","reason":"mailbox full"}` + resp := postBrevoTx(t, app, testBrevoTxSecret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + + row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID) + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if row.Classification != c.wantClass { + t.Errorf("classification = %q; want %q", row.Classification, c.wantClass) + } + // Only the 'delivered' event ever stamps delivered_at. + if row.DeliveredAt != nil { + t.Errorf("delivered_at must stay NULL on a %q event; got %v", c.event, row.DeliveredAt) + } + }) + } +} + +// ── 3. bug-bash #6 terminal-class guard: a LATE 'delivered' must NOT clobber a +// recorded 'bounced_hard'. This is the #6 guard area the brief calls out +// — it can ONLY be proven against a real row (sqlmock can't enforce the +// NOT IN row-state predicate). + +func TestBrevoTxWebhook_RealDB_LateDeliveredDoesNotClobberHardBounce(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + providerID := "msg-terminal-" + uuid.NewString()[:8] + // The row is ALREADY terminal — a hard bounce was recorded first. + seedForwarderSent(t, db, providerID, "bounced_hard") + + app := brevoTxRealApp(t, db) + // Out-of-order: Brevo's SMTP-accept 'delivered' arrives AFTER the bounce. + body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `"}` + resp := postBrevoTx(t, app, testBrevoTxSecret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + + row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID) + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if row.Classification != "bounced_hard" { + t.Errorf("terminal class clobbered: classification = %q; want bounced_hard preserved (bug-bash #6)", row.Classification) + } + if row.DeliveredAt != nil { + t.Errorf("delivered_at must NOT be stamped when the terminal class is preserved; got %v", row.DeliveredAt) + } +} + +// ── 3b. the guard also preserves the OTHER terminal classes, and a 'delivered' +// DOES win when the prior state is a non-terminal 'deferred'. This pins +// the exact NOT IN set so a future edit that drops a class from the +// guard fails here. + +func TestBrevoTxWebhook_RealDB_TerminalGuardSetIsExact(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + app := brevoTxRealApp(t, db) + + cases := []struct { + name string + seedClass string + wantClassAfter string + wantDelivered bool + }{ + // Terminal classes: a late 'delivered' is a no-op on classification. + {"hard_bounce_preserved", "bounced_hard", "bounced_hard", false}, + {"soft_bounce_preserved", "bounced_soft", "bounced_soft", false}, + {"rejected_preserved", "rejected", "rejected", false}, + {"complaint_preserved", "complaint", "complaint", false}, + {"unsubscribed_preserved", "unsubscribed", "unsubscribed", false}, + // Non-terminal classes: a 'delivered' SHOULD win (deferred is transient; + // 'success' is the worker's API-acceptance placeholder). + {"deferred_upgraded", "deferred", "delivered", true}, + {"success_upgraded", "success", "delivered", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + providerID := "msg-guard-" + c.name + "-" + uuid.NewString()[:8] + seedForwarderSent(t, db, providerID, c.seedClass) + + body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `"}` + resp := postBrevoTx(t, app, testBrevoTxSecret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + row, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID) + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if row.Classification != c.wantClassAfter { + t.Errorf("seed=%q: classification = %q; want %q", c.seedClass, row.Classification, c.wantClassAfter) + } + if (row.DeliveredAt != nil) != c.wantDelivered { + t.Errorf("seed=%q: delivered_at set = %v; want %v", c.seedClass, row.DeliveredAt != nil, c.wantDelivered) + } + }) + } +} + +// ── 4. idempotency: a re-delivery of the same 'delivered' event is a no-op on +// the value side and does NOT push delivered_at backwards (GREATEST). + +func TestBrevoTxWebhook_RealDB_DeliveredIsIdempotent(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + + providerID := "msg-idem-" + uuid.NewString()[:8] + seedForwarderSent(t, db, providerID, "success") + app := brevoTxRealApp(t, db) + body := `{"event":"delivered","email":"u@example.com","message-id":"` + providerID + `"}` + + // First delivery stamps delivered_at. + if resp := postBrevoTx(t, app, testBrevoTxSecret, body); resp.StatusCode != http.StatusOK { + t.Fatalf("first POST status = %d; want 200", resp.StatusCode) + } + first, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID) + if err != nil || first.DeliveredAt == nil { + t.Fatalf("after first delivery: row=%+v err=%v", first, err) + } + firstTS := *first.DeliveredAt + + // A re-delivery (Brevo retry) must keep classification + must not move + // delivered_at backwards (GREATEST). It may bump forward by sub-second; we + // assert it never regresses. + time.Sleep(10 * time.Millisecond) + if resp := postBrevoTx(t, app, testBrevoTxSecret, body); resp.StatusCode != http.StatusOK { + t.Fatalf("second POST status = %d; want 200", resp.StatusCode) + } + second, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, providerID) + if err != nil { + t.Fatalf("Lookup after re-delivery: %v", err) + } + if second.Classification != "delivered" { + t.Errorf("classification after re-delivery = %q; want delivered", second.Classification) + } + if second.DeliveredAt == nil || second.DeliveredAt.Before(firstTS) { + t.Errorf("delivered_at regressed: first=%v second=%v (GREATEST must keep it monotonic)", firstTS, second.DeliveredAt) + } +} + +// ── 5. unknown messageId (no matching row) → 200 matched:false, NO row created. + +func TestBrevoTxWebhook_RealDB_UnknownMessageIDIsNoOp(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + app := brevoTxRealApp(t, db) + + orphanID := "msg-orphan-" + uuid.NewString()[:8] + body := `{"event":"delivered","email":"u@example.com","message-id":"` + orphanID + `"}` + resp := postBrevoTx(t, app, testBrevoTxSecret, body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200 (Brevo retries on non-2xx — orphans must NOT amplify retry)", resp.StatusCode) + } + // The handler must NEVER INSERT a ledger row for an orphan event. + if _, err := handlers.LookupForwarderSentByProviderID(context.Background(), db, orphanID); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("orphan messageId must leave no forwarder_sent row; lookup err = %v (want sql.ErrNoRows)", err) + } +} diff --git a/internal/handlers/status_realdb_integration_test.go b/internal/handlers/status_realdb_integration_test.go new file mode 100644 index 0000000..df85ae7 --- /dev/null +++ b/internal/handlers/status_realdb_integration_test.go @@ -0,0 +1,305 @@ +package handlers_test + +// status_realdb_integration_test.go — REAL-DB + REAL-Redis integration coverage +// for GET /api/v1/status (handlers.StatusHandler). +// +// WHY (integration-coverage wave 1, 2026-06-04): +// +// status_test.go / status_final_test.go drive the handler entirely through +// sqlmock — they assert the SELECT strings but never prove the in-memory +// bucketing (15-min slots, current_status derivation, 7d/30d uptime percent) +// is correct against real rows pulled through a real Postgres driver, nor that +// the cache.GetOrSet round-trip against real Redis works end to end. +// +// These tests seed real service_components + uptime_samples rows, hit the +// public endpoint through a Fiber app wired to a real *sql.DB + *redis.Client, +// and assert the computed payload (current_status, uptime percentages, cache +// behaviour). Skipped (not failed) when the test DB/Redis are unreachable so +// the hermetic `-short` gate stays green; CI supplies both. + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + + "instant.dev/internal/handlers" + "instant.dev/internal/testhelpers" +) + +// statusComponentResp mirrors the public per-component shape (subset asserted). +type statusComponentResp struct { + Slug string `json:"slug"` + Name string `json:"name"` + Category string `json:"category"` + CurrentStatus string `json:"current_status"` + Uptime7dPct float64 `json:"uptime_7d_pct"` + Uptime30dPct float64 `json:"uptime_30d_pct"` + Last24hSamples []bool `json:"last_24h_samples"` +} + +type statusResp struct { + OK bool `json:"ok"` + FreshnessSeconds int `json:"freshness_seconds"` + Components []statusComponentResp `json:"components"` + CurrentIncidents []json.RawMessage `json:"current_incidents"` +} + +// statusRealApp mounts GET /api/v1/status wired to real DB + Redis, matching +// the production registration (public, no auth). +func statusRealApp(t *testing.T, app *fiber.App, h *handlers.StatusHandler) { + t.Helper() + app.Get("/api/v1/status", h.Get) +} + +// getStatus issues GET /api/v1/status and decodes the payload. +func getStatus(t *testing.T, app *fiber.App) (*http.Response, statusResp) { + t.Helper() + resp := testhelpers.GetReq(t, app, "/api/v1/status") + var sr statusResp + if resp.StatusCode == http.StatusOK { + testhelpers.DecodeJSON(t, resp, &sr) + } + return resp, sr +} + +// ── 1. operational component: all-healthy samples → operational + 100% uptime ── + +func TestStatus_RealDB_OperationalComponent(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + rdb, rcleanup := testhelpers.SetupTestRedis(t) + defer rcleanup() + + ctx := context.Background() + slug := "api-int-" + time.Now().Format("150405.000000") + if _, err := db.ExecContext(ctx, `INSERT INTO service_components (slug, display_name, category, description) + VALUES ($1, 'API (integration)', 'core', 'agent-facing API')`, slug); err != nil { + t.Fatalf("seed component: %v", err) + } + // 30 healthy samples over the last 2 hours. + now := time.Now().UTC() + for i := 0; i < 30; i++ { + ts := now.Add(-time.Duration(i) * 4 * time.Minute) + if _, err := db.ExecContext(ctx, `INSERT INTO uptime_samples (component_slug, sampled_at, healthy, latency_ms) + VALUES ($1, $2, true, 12)`, slug, ts); err != nil { + t.Fatalf("seed sample: %v", err) + } + } + + app := fiber.New() + statusRealApp(t, app, handlers.NewStatusHandler(db, rdb)) + + resp, sr := getStatus(t, app) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + if !sr.OK { + t.Error("payload ok must be true") + } + comp := findComponent(t, sr, slug) + if comp.CurrentStatus != "operational" { + t.Errorf("current_status = %q; want operational (all probes healthy)", comp.CurrentStatus) + } + if comp.Uptime7dPct != 100 { + t.Errorf("uptime_7d_pct = %v; want 100", comp.Uptime7dPct) + } + if len(comp.Last24hSamples) != 96 { + t.Errorf("last_24h_samples len = %d; want 96", len(comp.Last24hSamples)) + } +} + +// ── 2. degraded/down component: a recent unhealthy probe drives current_status +// off "operational" and depresses the uptime percentage. ── + +func TestStatus_RealDB_UnhealthyComponentReflected(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + rdb, rcleanup := testhelpers.SetupTestRedis(t) + defer rcleanup() + + ctx := context.Background() + slug := "worker-int-" + time.Now().Format("150405.000000") + if _, err := db.ExecContext(ctx, `INSERT INTO service_components (slug, display_name, category) + VALUES ($1, 'Worker (integration)', 'core')`, slug); err != nil { + t.Fatalf("seed component: %v", err) + } + now := time.Now().UTC() + // Most-recent slot: a single unhealthy probe → 0% healthy → "down". + if _, err := db.ExecContext(ctx, `INSERT INTO uptime_samples (component_slug, sampled_at, healthy) + VALUES ($1, $2, false)`, slug, now.Add(-1*time.Minute)); err != nil { + t.Fatalf("seed unhealthy: %v", err) + } + // Some older healthy probes so the 7d window isn't all-bad. + for i := 1; i <= 9; i++ { + if _, err := db.ExecContext(ctx, `INSERT INTO uptime_samples (component_slug, sampled_at, healthy) + VALUES ($1, $2, true)`, slug, now.Add(-time.Duration(i)*time.Hour)); err != nil { + t.Fatalf("seed healthy: %v", err) + } + } + + app := fiber.New() + statusRealApp(t, app, handlers.NewStatusHandler(db, rdb)) + + resp, sr := getStatus(t, app) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200", resp.StatusCode) + } + comp := findComponent(t, sr, slug) + // The most-recent slot is 1/1 unhealthy → below the 50% degraded cutoff → "down". + if comp.CurrentStatus != "down" { + t.Errorf("current_status = %q; want down (most recent probe unhealthy)", comp.CurrentStatus) + } + // 7d uptime: 9 healthy / 10 total = 90%. + if comp.Uptime7dPct <= 0 || comp.Uptime7dPct >= 100 { + t.Errorf("uptime_7d_pct = %v; want a partial value in (0,100) reflecting the unhealthy probe", comp.Uptime7dPct) + } +} + +// ── 3. empty DB (no components) → ok:true, zero components, no incidents. The +// public status page must render a clean empty state, never a 500. ── + +func TestStatus_RealDB_EmptyComponentsCleanState(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + rdb, rcleanup := testhelpers.SetupTestRedis(t) + defer rcleanup() + + // Ensure no components leak in from a reused DB. + if _, err := db.ExecContext(context.Background(), `DELETE FROM uptime_samples`); err != nil { + t.Fatalf("clear samples: %v", err) + } + if _, err := db.ExecContext(context.Background(), `DELETE FROM service_components`); err != nil { + t.Fatalf("clear components: %v", err) + } + + app := fiber.New() + statusRealApp(t, app, handlers.NewStatusHandler(db, rdb)) + + resp, sr := getStatus(t, app) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200 (empty state, never 500)", resp.StatusCode) + } + if !sr.OK { + t.Error("ok must be true even with no components") + } + if len(sr.Components) != 0 { + t.Errorf("components = %d; want 0", len(sr.Components)) + } + if sr.CurrentIncidents == nil { + t.Error("current_incidents must serialise as [] (non-nil), not null") + } + if sr.FreshnessSeconds != 60 { + t.Errorf("freshness_seconds = %d; want 60", sr.FreshnessSeconds) + } +} + +// ── 4. cache round-trip: the SECOND request is served from Redis. We prove the +// cache.GetOrSet write/read path by mutating the DB after the first +// request and asserting the second request still returns the CACHED +// (pre-mutation) payload, then a fresh handler (cold cache key) sees the +// new row. This exercises the real Redis serialise/deserialise path that +// sqlmock tests can't reach. ── + +func TestStatus_RealDB_CacheServesSecondRequest(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + rdb, rcleanup := testhelpers.SetupTestRedis(t) + defer rcleanup() + ctx := context.Background() + + // Clean slate so the cached payload is deterministic. + if _, err := db.ExecContext(ctx, `DELETE FROM uptime_samples`); err != nil { + t.Fatalf("clear samples: %v", err) + } + if _, err := db.ExecContext(ctx, `DELETE FROM service_components`); err != nil { + t.Fatalf("clear components: %v", err) + } + // Make sure the shared status cache key is cold before we begin. + rdb.Del(ctx, "status:public:v1") + + slug := "edge-int-" + time.Now().Format("150405.000000") + if _, err := db.ExecContext(ctx, `INSERT INTO service_components (slug, display_name, category) + VALUES ($1, 'Edge (integration)', 'edge')`, slug); err != nil { + t.Fatalf("seed component: %v", err) + } + + app := fiber.New() + statusRealApp(t, app, handlers.NewStatusHandler(db, rdb)) + + // First request: cache miss → computes from DB (1 component) → writes Redis. + _, sr1 := getStatus(t, app) + if len(sr1.Components) != 1 { + t.Fatalf("first request components = %d; want 1", len(sr1.Components)) + } + + // Mutate the DB: add a second component. A cache HIT must NOT see it. + slug2 := "edge2-int-" + time.Now().Format("150405.000000") + if _, err := db.ExecContext(ctx, `INSERT INTO service_components (slug, display_name, category) + VALUES ($1, 'Edge2 (integration)', 'edge')`, slug2); err != nil { + t.Fatalf("seed component 2: %v", err) + } + + // Second request within the 60s TTL: served from Redis → still 1 component. + _, sr2 := getStatus(t, app) + if len(sr2.Components) != 1 { + t.Errorf("second request components = %d; want 1 (must be served from the 60s Redis cache, NOT recomputed)", len(sr2.Components)) + } + + // Cold the cache key → a fresh compute now sees BOTH components, proving the + // DB mutation was real and only the cache masked it. + rdb.Del(ctx, "status:public:v1") + _, sr3 := getStatus(t, app) + if len(sr3.Components) != 2 { + t.Errorf("after cache bust components = %d; want 2 (the second component must surface once the cache key is cold)", len(sr3.Components)) + } +} + +// ── 5. Redis-down (nil client): compute falls through to the DB. The status +// page must stay up when the cache is degraded — which is exactly when +// operators most need an honest reading. ── + +func TestStatus_RealDB_NilRedisFallsThroughToDB(t *testing.T) { + db, cleanup := testhelpers.SetupTestDB(t) + defer cleanup() + ctx := context.Background() + + slug := "prov-int-" + time.Now().Format("150405.000000") + if _, err := db.ExecContext(ctx, `INSERT INTO service_components (slug, display_name, category) + VALUES ($1, 'Provisioner (integration)', 'core')`, slug); err != nil { + t.Fatalf("seed component: %v", err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO uptime_samples (component_slug, sampled_at, healthy) + VALUES ($1, now(), true)`, slug); err != nil { + t.Fatalf("seed sample: %v", err) + } + + app := fiber.New() + // nil Redis — cache.GetOrSet degrades to a per-request DB fetch. + statusRealApp(t, app, handlers.NewStatusHandler(db, (*redis.Client)(nil))) + + resp, sr := getStatus(t, app) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d; want 200 even with Redis down", resp.StatusCode) + } + if findComponent(t, sr, slug).Slug != slug { + t.Errorf("seeded component %q missing from DB-fallback payload", slug) + } +} + +// findComponent returns the component with the given slug, failing the test if +// it is absent. +func findComponent(t *testing.T, sr statusResp, slug string) statusComponentResp { + t.Helper() + for _, c := range sr.Components { + if c.Slug == slug { + return c + } + } + t.Fatalf("component %q not found in status payload (%d components)", slug, len(sr.Components)) + return statusComponentResp{} +}