Skip to content

Commit e9d618a

Browse files
test(worker): real-DB integration harness + Tier-1 job coverage (toward 100% integration) (#87)
* test(worker): real-DB integration harness + Tier-1 job coverage (toward 100% integration) Add internal/testhelpers — a real-Postgres integration harness mirroring api's testhelpers.SetupTestDB. Before this, only 3 of ~59 worker job files exercised a real DB; the rest were fake/mock-driven (INTEGRATION-COVERAGE- PLAN-2026-06-04.md §5 #1 flagged worker as the platform's biggest integration gap). New REAL-DB round-trip integration tests (seed real rows → run Work() against live Postgres → assert the DB state transition + side effects): - deploy_status_reconcile + deploy_failure_autopsy (the 2026-05-30 silent-deploy-failure triad — highest value): build-Job-Failed → row flips to 'failed' + autopsy row; healthy transition; stuck-building reap; autopsy upsert + error_message stamp + deploy.failed audit emit idempotency. - entitlement_reconciler: drifted resource → applied_conn_limit persisted; no-drift row left untouched. Harness gates via t.Skip under -short / when no DB is reachable, so `make gate` (deploy.yml) and ci.yml (no DB service) skip cleanly — matching the existing propagation_runner_integration_test.go gating. Tests run locally against postgres://postgres@localhost:5432/instant_dev_test. ensureSchema also (re)creates the deployment_events failure_autopsy partial-unique index defensively: a plain CREATE UNIQUE INDEX IF NOT EXISTS silently no-ops when the canonical name is taken by an index on a different table (observed on a dev box), so the harness probes the actual table and creates a collision-proof index when absent. Integration-only coverage (per-file, real-DB tests ONLY, the §1.4 mechanism): deploy_status_reconcile.go : 0.0% -> 60.0% (90/150 stmts) deploy_failure_autopsy.go : 0.0% -> 42.2% (94/223 stmts) entitlement_reconciler.go : 0.0% -> 44.0% (74/168 stmts) All three had ZERO integration coverage before (propagation_runner was the only prior worker integration test and touches none of these files). Jobs-package self-coverage (full suite): 97.2%. make gate GREEN. golangci-lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(worker): give integration harness its own 100% coverage (fix patch-coverage gate) PR #87's `coverage` CI check failed: diff-cover's 100%-patch gate flagged 253 uncovered lines in the new internal/testhelpers/testhelpers.go. Two interacting causes: 1. SetupTestDB skipped under `testing.Short()`. coverage.yml's "Generate coverage" step runs `go test ./... -short` against a real Postgres service, so the -short guard skipped the integration tests that drive the harness — leaving every line at 0%. Removed the -short guard; gating is now purely on DB reachability (matches api's testhelpers.SetupTestDB), so the no-DB workflows (deploy.yml -short, ci.yml -race) still skip cleanly via the ping arm while coverage.yml's Postgres service exercises the harness. 2. testhelpers.go is a non-_test.go file (it must be importable by the internal/jobs integration tests), so it lands in coverage.out — but Go credits coverage to the package whose tests ran (jobs), not testhelpers. Added testhelpers_smoke_test.go, an in-package suite that gives the harness its own coverage. Mirrors api's testapp_smoke_test.go. The harness's fail/skip arms route through swappable tFatalf/tSkipf seams (the platform "test seams, not waivers" rule) so the error paths are covered with a closed DB; the create-index and bare-schema-fallback branches are driven against the real DB; the seam defaults are covered via isolated goroutines. Replaced the provably-unreachable sql.Open error arm with pq.NewConnector (a reachable, tested DSN-parse skip). Result: internal/testhelpers at 100% statements; diff-cover patch gate 100% / 0 missing; project floor 97.2% (>=95). `make gate` green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(worker): cover the TEST_DATABASE_URL default-DSN fallback in SetupTestDB The merged-HEAD coverage run flagged lines 105-106 (the `dsn == "" -> DefaultTestDBURL` fallback) as the last 2 uncovered diff lines: CI always exports TEST_DATABASE_URL, so the fallback only runs when the env var is explicitly unset. Added TestIntegration_SetupTestDB_DefaultDSNFallback, which clears the env var (t.Setenv "") and asserts SetupTestDB resolves to the default localhost DSN. Brings internal/testhelpers back to 100% under CI's environment; diff-cover patch gate now 100% / 0 missing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 06e12b0 commit e9d618a

4 files changed

Lines changed: 1150 additions & 0 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package jobs
2+
3+
// deploy_reconcile_integration_test.go — REAL-Postgres integration tests for
4+
// the silent-deploy-failure triad (2026-05-30 incident class). These are the
5+
// highest-value worker integration tests per INTEGRATION-COVERAGE-PLAN-
6+
// 2026-06-04.md §5 #2: deploy_status_reconcile + deploy_failure_autopsy were
7+
// fake-tested only, which is exactly the regression class that hides — a sqlmock
8+
// expectation passes whether or not the real SQL is valid against the live
9+
// schema.
10+
//
11+
// Unlike the sibling *_test.go files in this package (which drive the SQL with
12+
// go-sqlmock), these tests seed real rows in a live platform Postgres, run the
13+
// job's Work()/captureDeploymentAutopsy against that DB, and assert the actual
14+
// row state transition + side effects (status flip, deployment_events upsert,
15+
// audit_log deploy.failed emit + its idempotency, error_message stamp).
16+
//
17+
// The k8s leg stays faked (no live cluster) — the fakeDeployStatusK8s /
18+
// fakeAutopsyK8sCov helpers from deploy_lifecycle_coverage_test.go +
19+
// deploy_status_reconcile_job_failed_test.go supply the cluster state. The DB
20+
// leg is REAL. That split is the design the plan prescribes (§3 wave 3): "the
21+
// deploy/k8s jobs use a fake clientset for the k8s leg but a real DB for the
22+
// row mutation."
23+
//
24+
// GATING: testhelpers.SetupTestDB skips when no DB is reachable, so `make gate`
25+
// (deploy.yml) and ci.yml (no DB service) skip these cleanly. They run locally
26+
// against postgres://postgres@localhost:5432/instant_dev_test and wherever a
27+
// TEST_DATABASE_URL is supplied (developer DB, coverage.yml's postgres service).
28+
29+
import (
30+
"context"
31+
"testing"
32+
"time"
33+
34+
"instant.dev/worker/internal/testhelpers"
35+
)
36+
37+
// TestIntegration_DeployStatusReconcile_JobFailedFlipsToFailed is the PRIMARY
38+
// real-DB guard for the silent-deploy-failure bug class (Bug A of the
39+
// 2026-05-30 triad). Setup mirrors the user's incident:
40+
//
41+
// - a deployments row at status='building' (api goroutine crashed mid-build
42+
// or never stamped the terminal status)
43+
// - the runtime Deployment was never created → GetDeployment returns NotFound
44+
// - the kaniko build Job is Failed (BackoffLimitExceeded) but survives within
45+
// its TTLSecondsAfterFinished window
46+
//
47+
// The fixed reconciler MUST: (1) flip the REAL row to 'failed', and (2) write a
48+
// REAL deployment_events failure_autopsy row (the in-sweep capture). The
49+
// sqlmock sibling pins the SQL string; THIS pins that the SQL is valid against
50+
// the live schema and the row actually transitions — a sqlmock expectation
51+
// would pass even if the UPDATE's WHERE clause silently matched zero rows.
52+
func TestIntegration_DeployStatusReconcile_JobFailedFlipsToFailed(t *testing.T) {
53+
db, cleanup := testhelpers.SetupTestDB(t)
54+
defer cleanup()
55+
56+
teamID := testhelpers.SeedTeam(t, db, "pro")
57+
// provider_id "app-itest1" → namespace "instant-deploy-itest1".
58+
deployID := testhelpers.SeedDeployment(t, db, teamID, deployStatusBuilding, "app-itest1")
59+
60+
k8s := newFakeDeployStatusK8s()
61+
// Runtime Deployment missing (build never reached apply). Build Job Failed.
62+
k8s.jobs["instant-deploy-itest1|build-itest1"] = jobBackoffLimitExceeded()
63+
64+
w := NewDeployStatusReconciler(db, k8s).WithAutopsyK8s(&fakeAutopsyK8sCov{})
65+
if err := w.Work(context.Background(), fakeRiverJob[DeployStatusReconcileArgs]()); err != nil {
66+
t.Fatalf("Work: %v", err)
67+
}
68+
69+
gotStatus, _ := testhelpers.DeploymentStatus(t, db, deployID)
70+
if gotStatus != deployStatusFailed {
71+
t.Errorf("deployment status = %q, want %q (build Job Failed must flip the real row)", gotStatus, deployStatusFailed)
72+
}
73+
74+
// The in-sweep autopsy must have written a REAL deployment_events row.
75+
if _, ok := testhelpers.AutopsyRow(t, db, deployID); !ok {
76+
t.Error("no failure_autopsy deployment_events row written — the in-sweep capture did not round-trip to the DB")
77+
}
78+
}
79+
80+
// TestIntegration_DeployStatusReconcile_HealthyTransition asserts the happy
81+
// path round-trips: a 'building' row whose runtime Deployment is now healthy
82+
// (AvailableReplicas>=1) is flipped to 'healthy' in the REAL DB. This pins that
83+
// the updateStatus UPDATE's WHERE status IN (...) guard actually matches the
84+
// row (a sqlmock test cannot catch a guard that excludes the live row).
85+
func TestIntegration_DeployStatusReconcile_HealthyTransition(t *testing.T) {
86+
db, cleanup := testhelpers.SetupTestDB(t)
87+
defer cleanup()
88+
89+
teamID := testhelpers.SeedTeam(t, db, "hobby")
90+
deployID := testhelpers.SeedDeployment(t, db, teamID, deployStatusBuilding, "app-itest2")
91+
92+
k8s := newFakeDeployStatusK8s()
93+
k8s.objs["instant-deploy-itest2|app-itest2"] = newHealthyDeployment()
94+
95+
w := NewDeployStatusReconciler(db, k8s)
96+
if err := w.Work(context.Background(), fakeRiverJob[DeployStatusReconcileArgs]()); err != nil {
97+
t.Fatalf("Work: %v", err)
98+
}
99+
100+
gotStatus, _ := testhelpers.DeploymentStatus(t, db, deployID)
101+
if gotStatus != deployStatusHealthy {
102+
t.Errorf("deployment status = %q, want %q", gotStatus, deployStatusHealthy)
103+
}
104+
}
105+
106+
// TestIntegration_DeployStatusReconcile_StuckBuildingReaped covers sweep
107+
// finding #5 (Bug A's tier-cap leak): a 'building' row with an EMPTY
108+
// provider_id whose age exceeds stuckBuildingGrace is reaped to 'failed' with
109+
// the stuck-building error_message stamped. This exercises reapStuckBuilding's
110+
// double-guarded UPDATE against the live schema — the WHERE provider_id IS NULL
111+
// OR provider_id = ” guard must match the real NULL row.
112+
func TestIntegration_DeployStatusReconcile_StuckBuildingReaped(t *testing.T) {
113+
db, cleanup := testhelpers.SetupTestDB(t)
114+
defer cleanup()
115+
116+
teamID := testhelpers.SeedTeam(t, db, "hobby")
117+
// Empty provider_id → SeedDeployment writes NULL.
118+
deployID := testhelpers.SeedDeployment(t, db, teamID, deployStatusBuilding, "")
119+
120+
// Age the row past the 15m grace window so the reaper fires.
121+
if _, err := db.Exec(
122+
`UPDATE deployments SET created_at = $1 WHERE id = $2`,
123+
time.Now().Add(-stuckBuildingGrace-time.Minute), deployID,
124+
); err != nil {
125+
t.Fatalf("age row: %v", err)
126+
}
127+
128+
// k8s is present but the row has no provider_id, so no namespace is
129+
// derivable — the reaper path runs without any k8s Get.
130+
w := NewDeployStatusReconciler(db, newFakeDeployStatusK8s())
131+
if err := w.Work(context.Background(), fakeRiverJob[DeployStatusReconcileArgs]()); err != nil {
132+
t.Fatalf("Work: %v", err)
133+
}
134+
135+
gotStatus, gotErr := testhelpers.DeploymentStatus(t, db, deployID)
136+
if gotStatus != deployStatusFailed {
137+
t.Errorf("stuck-building row status = %q, want %q (reaper must free the tier cap)", gotStatus, deployStatusFailed)
138+
}
139+
if !gotErr.Valid || gotErr.String != stuckBuildingReapMessage {
140+
t.Errorf("error_message = %q (valid=%v), want %q", gotErr.String, gotErr.Valid, stuckBuildingReapMessage)
141+
}
142+
}
143+
144+
// TestIntegration_DeployFailureAutopsy_UpsertAndAuditEmit is the real-DB guard
145+
// for Bug B of the triad. captureDeploymentAutopsy against a live DB must:
146+
//
147+
// 1. UPSERT a deployment_events failure_autopsy row (idempotent on re-run via
148+
// the partial-unique index — a second call must NOT create a duplicate),
149+
// 2. stamp deployments.error_message with "<reason>: <hint snippet>", and
150+
// 3. emit an audit_log kind='deploy.failed' row exactly ONCE even across
151+
// repeated ticks (the idempotency guard added in bug bash 2026-06-02 #15).
152+
//
153+
// The ON CONFLICT ... WHERE kind='failure_autopsy' clause + the audit dedup
154+
// probe are SQL that sqlmock cannot validate against the real schema — this is
155+
// where the partial-unique-index gap (the dev-box schema lacked the index)
156+
// would surface as a hard error rather than a green mock.
157+
func TestIntegration_DeployFailureAutopsy_UpsertAndAuditEmit(t *testing.T) {
158+
db, cleanup := testhelpers.SetupTestDB(t)
159+
defer cleanup()
160+
161+
teamID := testhelpers.SeedTeam(t, db, "pro")
162+
deployID := testhelpers.SeedDeployment(t, db, teamID, deployStatusFailed, "app-itest3")
163+
164+
// Autopsy k8s returns OOMKilled pod logs so the captured reason is concrete
165+
// (not Unknown) — proves the k8s→DB plumbing end-to-end.
166+
autopsy := &fakeAutopsyK8sCov{
167+
logs: []string{"panic: out of memory", "exit status 137"},
168+
}
169+
170+
ctx := context.Background()
171+
172+
// First capture.
173+
captureDeploymentAutopsy(ctx, db, deployID, "app-itest3", autopsy)
174+
175+
reason, ok := testhelpers.AutopsyRow(t, db, deployID)
176+
if !ok {
177+
t.Fatal("no failure_autopsy row after first capture")
178+
}
179+
if reason == "" {
180+
t.Error("autopsy reason is empty — capture did not populate the row")
181+
}
182+
183+
// error_message must be stamped (it was NULL on seed).
184+
_, gotErr := testhelpers.DeploymentStatus(t, db, deployID)
185+
if !gotErr.Valid || gotErr.String == "" {
186+
t.Error("deployments.error_message not stamped by autopsy")
187+
}
188+
189+
// audit_log deploy.failed emitted exactly once.
190+
if n := testhelpers.CountAuditLog(t, db, auditKindDeployFailed, deployID.String()); n != 1 {
191+
t.Errorf("deploy.failed audit rows after first capture = %d, want 1", n)
192+
}
193+
194+
// Second capture (idempotent re-tick): MUST NOT duplicate either the
195+
// autopsy row or the audit_log row.
196+
captureDeploymentAutopsy(ctx, db, deployID, "app-itest3", autopsy)
197+
198+
if n := testhelpers.CountAuditLog(t, db, auditKindDeployFailed, deployID.String()); n != 1 {
199+
t.Errorf("deploy.failed audit rows after SECOND capture = %d, want 1 — idempotency guard regressed (duplicate failure emails)", n)
200+
}
201+
202+
// Still exactly one autopsy row (partial-unique index + ON CONFLICT).
203+
var autopsyRows int
204+
if err := db.QueryRow(
205+
`SELECT count(*) FROM deployment_events WHERE deployment_id = $1 AND kind = 'failure_autopsy'`,
206+
deployID,
207+
).Scan(&autopsyRows); err != nil {
208+
t.Fatalf("count autopsy rows: %v", err)
209+
}
210+
if autopsyRows != 1 {
211+
t.Errorf("failure_autopsy rows after two captures = %d, want 1 (ON CONFLICT upsert regressed)", autopsyRows)
212+
}
213+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package jobs
2+
3+
// entitlement_reconciler_integration_test.go — REAL-Postgres integration test
4+
// for the entitlement reconciler's Postgres connection-cap regrade round-trip.
5+
//
6+
// Per INTEGRATION-COVERAGE-PLAN-2026-06-04.md §5 #1, entitlement_reconciler is
7+
// a Tier-1 (data-adjacent) job whose DB round-trip was fake-tested only. The
8+
// sibling entitlement_reconciler_test.go drives the SELECT/UPDATE through
9+
// go-sqlmock; THIS test seeds a real drifted resources row, runs Work() against
10+
// a live platform Postgres with a stub provisioner regrader, and asserts the
11+
// row's applied_conn_limit was actually persisted by the worker's UPDATE.
12+
//
13+
// The provisioner gRPC leg stays stubbed (stubRegrader returns
14+
// {Applied:true, AppliedConnLimit:5}) — the DB leg (the drift SELECT + the
15+
// UPDATE resources SET applied_conn_limit) is REAL. This is the convergence
16+
// signal the job depends on in production: a sqlmock test passes whether or not
17+
// the UPDATE's WHERE id = $2 matches the live row; this proves it does.
18+
//
19+
// GATING: testhelpers.SetupTestDB skips when no DB is reachable, so the regular
20+
// gate (deploy.yml / ci.yml, no Postgres service) stays green. It runs against a
21+
// real Postgres wherever one is supplied (developer DB, coverage.yml service).
22+
23+
import (
24+
"context"
25+
"database/sql"
26+
"testing"
27+
28+
"instant.dev/worker/internal/testhelpers"
29+
)
30+
31+
// TestIntegration_EntitlementReconciler_PersistsRegradedConnLimit seeds a
32+
// drifted Postgres resource (applied_conn_limit = NULL, never re-graded) on a
33+
// pro-tier team, runs the reconciler with a stub regrader that reports
34+
// {Applied:true, AppliedConnLimit:5}, and asserts the worker persisted that
35+
// value to the REAL row.
36+
//
37+
// Drift detection: shouldRegrade returns drift=true for a NULL applied limit on
38+
// any non-ephemeral tier. The stub stands in for the provisioner's
39+
// RegradeResource; the worker's `UPDATE resources SET applied_conn_limit = $1
40+
// WHERE id = $2` is the integration assertion target.
41+
func TestIntegration_EntitlementReconciler_PersistsRegradedConnLimit(t *testing.T) {
42+
db, cleanup := testhelpers.SetupTestDB(t)
43+
defer cleanup()
44+
45+
teamID := testhelpers.SeedTeam(t, db, "pro")
46+
// resource.tier = pro (the per-row snapshot the reconciler resolves caps
47+
// from), applied_conn_limit = NULL → drifts.
48+
resID, _ := testhelpers.SeedResource(t, db, teamID, "postgres", "pro", sql.NullInt64{})
49+
50+
// Scope the sweep to ONLY this team so it cannot touch any other rows that
51+
// may exist in a shared local DB (and so the assertion is deterministic).
52+
t.Setenv("ENTITLEMENT_RECONCILE_TEAM", teamID.String())
53+
54+
stub := &stubRegrader{} // returns Applied:true, AppliedConnLimit:5
55+
reg := liveRegistry(t)
56+
w := NewEntitlementReconcilerWorker(db, reg, stub)
57+
58+
if err := w.Work(context.Background(), fakeEntitlementJob()); err != nil {
59+
t.Fatalf("Work: %v", err)
60+
}
61+
62+
// The stub must have been asked to regrade our drifted row at least once.
63+
if got := int(stub.calls.Load()); got < 1 {
64+
t.Fatalf("RegradeResource called %d times, want >= 1 (drifted row should have been regraded)", got)
65+
}
66+
67+
// The REAL row's applied_conn_limit must now equal the stub's reported value.
68+
got := testhelpers.AppliedConnLimit(t, db, resID)
69+
if !got.Valid {
70+
t.Fatal("applied_conn_limit is still NULL — the worker's UPDATE did not persist against the live row")
71+
}
72+
if got.Int64 != 5 {
73+
t.Errorf("applied_conn_limit = %d, want 5 (the value the stub regrader reported)", got.Int64)
74+
}
75+
}
76+
77+
// TestIntegration_EntitlementReconciler_NoDriftLeavesRowUntouched is the
78+
// complement: a resource whose applied_conn_limit already equals the entitled
79+
// cap for its tier must NOT be re-graded (no drift) and the row is left
80+
// untouched. This pins that the live drift SELECT + shouldRegrade decision
81+
// correctly identify the no-op case against real data — a regression that
82+
// always-regrades would burn provisioner RPCs every 5-minute tick.
83+
func TestIntegration_EntitlementReconciler_NoDriftLeavesRowUntouched(t *testing.T) {
84+
db, cleanup := testhelpers.SetupTestDB(t)
85+
defer cleanup()
86+
87+
reg := liveRegistry(t)
88+
entitled := reg.ConnectionsLimit("pro", "postgres")
89+
90+
teamID := testhelpers.SeedTeam(t, db, "pro")
91+
// applied_conn_limit already == entitled → no drift.
92+
resID, _ := testhelpers.SeedResource(t, db, teamID, "postgres", "pro",
93+
sql.NullInt64{Int64: int64(entitled), Valid: true})
94+
95+
t.Setenv("ENTITLEMENT_RECONCILE_TEAM", teamID.String())
96+
97+
stub := &stubRegrader{}
98+
w := NewEntitlementReconcilerWorker(db, reg, stub)
99+
100+
if err := w.Work(context.Background(), fakeEntitlementJob()); err != nil {
101+
t.Fatalf("Work: %v", err)
102+
}
103+
104+
if got := int(stub.calls.Load()); got != 0 {
105+
t.Errorf("RegradeResource called %d times, want 0 — a row at the entitled cap must NOT drift", got)
106+
}
107+
108+
// Row value unchanged.
109+
got := testhelpers.AppliedConnLimit(t, db, resID)
110+
if !got.Valid || got.Int64 != int64(entitled) {
111+
t.Errorf("applied_conn_limit = %v, want %d (untouched)", got, entitled)
112+
}
113+
}

0 commit comments

Comments
 (0)