Skip to content

Commit 5de37e8

Browse files
test(autopsy): producer↔consumer schema-parity for deploy-failure events (#70)
The worker (PRODUCER) writes deployment_events autopsy rows in upsertAutopsyRow; the api (CONSUMER) reads them back via models.GetDeploymentEvents and serves GET /api/v1/deployments/:id/events. The two live in SEPARATE Go modules (worker does NOT import the api), so there is NO compiler-enforced link between the columns the worker INSERTs and the columns/encoding the api SELECTs+unmarshals. A drift on either side (renamed column, changed last_lines encoding, exit_code type flip) would silently break the agent debug surface with no build error. Existing tests cover that the worker WRITES the row (deploy_failure_autopsy_test.go) and the api SERVES it (api PR #269's deploy_autodebug_path_test.go + deploy_events_endpoint_test.go). This adds the focused PARITY assertion: - TestAutopsySchemaParity_LastLinesEncodingMatchesAPIConsumer captures the EXACT last_lines bytes the worker binds (sqlmock Argument matcher) and runs the api's consumer logic (json.Unmarshal into []string) over them — if the worker ever changed the encoding away from json.Marshal([]string), the api /events handler would break and this reds first. - TestAutopsySchemaParity_ColumnSetMatchesAPIConsumer asserts the worker INSERTs exactly the column set the api Events handler SELECTs (deployment_id/kind/reason/exit_code/event/last_lines/hint, in order) via a regex over the INSERT — a column rename on either side reds. Cross-ref: api PR #269 (docs/ci/02-FAILURE-DIAGNOSIS-AND-AUTODEBUG.md §5.3). make gate green except a pre-existing local-only integration flake outside this diff (jobs/TestIntegration_BillingReconciler_ SkipsTestCohort, billing-downgrade against the shared local DB). CI (fresh DB) is authoritative; the new parity tests + all existing autopsy tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9a508db commit 5de37e8

1 file changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package jobs
2+
3+
// deploy_failure_autopsy_schema_parity_test.go — the PRODUCER↔CONSUMER schema
4+
// parity assertion for the deploy-failure auto-debug path (task #70,
5+
// docs/ci/02-FAILURE-DIAGNOSIS-AND-AUTODEBUG.md §5.3).
6+
//
7+
// THE CONTRACT THIS GUARDS
8+
//
9+
// The worker (PRODUCER) writes a deployment_events row in
10+
// upsertAutopsyRow (deploy_failure_autopsy.go). The api (CONSUMER) reads it
11+
// back in models.GetDeploymentEvents + models.GetLatestDeploymentAutopsy and
12+
// serves it as GET /api/v1/deployments/:id/events. The two live in SEPARATE Go
13+
// modules (the worker does NOT import the api — see the file header of
14+
// deploy_failure_autopsy.go), so there is NO compiler-enforced link between the
15+
// columns the worker INSERTs and the columns the api SELECTs. A drift on either
16+
// side (a renamed column, a changed last_lines encoding, an exit_code type
17+
// flip) would silently break the agent debug surface with NO build error.
18+
//
19+
// Existing tests already cover that the worker WRITES the row
20+
// (deploy_failure_autopsy_test.go: upsert idempotency, full-capture,
21+
// last_lines JSON round-trip) and that the api SERVES it
22+
// (api/.../deploy_events_endpoint_test.go + the new
23+
// api/.../deploy_autodebug_path_test.go). This test adds the focused PARITY
24+
// assertion: the exact JSON shape the worker writes for last_lines is exactly
25+
// what the api Events handler unmarshals, and the column SET the worker INSERTs
26+
// is the set the api SELECTs.
27+
//
28+
// HOW IT ASSERTS PARITY WITHOUT IMPORTING THE API
29+
//
30+
// The api consumer (models.GetDeploymentEvents) scans last_lines into a
31+
// []byte then `json.Unmarshal(raw, &[]string)`. We capture the EXACT bytes the
32+
// worker's upsertAutopsyRow binds to the last_lines column (via a sqlmock
33+
// argument matcher) and run the api's unmarshal logic over them — if the worker
34+
// ever changed the encoding (e.g. to a comma-joined string, or pq.Array), this
35+
// reds. The api's scan code is duplicated here as a small literal mirror so the
36+
// assertion is self-contained (the worker can't import the api models).
37+
38+
import (
39+
"context"
40+
"database/sql"
41+
"database/sql/driver"
42+
"encoding/json"
43+
"testing"
44+
45+
sqlmock "github.com/DATA-DOG/go-sqlmock"
46+
"github.com/google/uuid"
47+
)
48+
49+
// apiEventsColumns mirrors the column list api/internal/models.GetDeploymentEvents
50+
// SELECTs (minus id + created_at, which are DB-generated, not worker-written).
51+
// The worker's upsertAutopsyRow INSERTs exactly this set. Drift on either side
52+
// reds the parity check below. Kept as a literal (the worker can't import the
53+
// api) — the test header documents the cross-module contract this pins.
54+
var apiEventsColumns = []string{
55+
"deployment_id", "kind", "reason", "exit_code", "event", "last_lines", "hint",
56+
}
57+
58+
// lastLinesCapture is a sqlmock Argument matcher that records the value bound to
59+
// the last_lines column and always matches, so the INSERT proceeds. We then
60+
// assert the captured bytes unmarshal via the api's consumer logic.
61+
type lastLinesCapture struct {
62+
captured []byte
63+
matched bool
64+
}
65+
66+
// Match implements sqlmock.Argument. The worker binds last_lines as a
67+
// json.Marshal([]string) → []byte; capture that. (Any other arg type means the
68+
// worker changed the encoding — record it so the assertion can fail loudly.)
69+
func (c *lastLinesCapture) Match(v driver.Value) bool {
70+
c.matched = true
71+
switch b := v.(type) {
72+
case []byte:
73+
c.captured = b
74+
case string:
75+
c.captured = []byte(b)
76+
}
77+
return true
78+
}
79+
80+
// TestAutopsySchemaParity_LastLinesEncodingMatchesAPIConsumer captures the
81+
// EXACT last_lines value the worker writes and asserts it unmarshals to the
82+
// same []string via the api's consumer logic (json.Unmarshal into []string).
83+
// This is the producer↔consumer schema-parity assertion: if the worker ever
84+
// changed the last_lines encoding, the api /events handler would break — and
85+
// this test reds first.
86+
func TestAutopsySchemaParity_LastLinesEncodingMatchesAPIConsumer(t *testing.T) {
87+
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
88+
if err != nil {
89+
t.Fatalf("sqlmock.New: %v", err)
90+
}
91+
defer db.Close()
92+
93+
producerLines := []string{
94+
"npm ERR! code ELIFECYCLE",
95+
"FATAL ERROR: Reached heap limit — JavaScript heap out of memory",
96+
"", // empty line must survive the round-trip too
97+
}
98+
99+
cap := &lastLinesCapture{}
100+
// last_lines is the 6th positional arg in the INSERT
101+
// (deployment_id, kind, reason, exit_code, event, last_lines, hint).
102+
mock.ExpectExec(`INSERT INTO deployment_events`).
103+
WithArgs(
104+
sqlmock.AnyArg(), // deployment_id
105+
sqlmock.AnyArg(), // kind
106+
sqlmock.AnyArg(), // reason
107+
sqlmock.AnyArg(), // exit_code
108+
sqlmock.AnyArg(), // event
109+
cap, // last_lines ← captured here
110+
sqlmock.AnyArg(), // hint
111+
).
112+
WillReturnResult(sqlmock.NewResult(0, 1))
113+
114+
if err := upsertAutopsyRow(context.Background(), db, uuid.New(),
115+
workerFailureReasonOOMKilled,
116+
sql.NullInt32{Int32: 137, Valid: true},
117+
"OOMKilling: out of memory",
118+
producerLines,
119+
); err != nil {
120+
t.Fatalf("upsertAutopsyRow (producer write): %v", err)
121+
}
122+
if err := mock.ExpectationsWereMet(); err != nil {
123+
t.Fatalf("unmet sqlmock expectations: %v", err)
124+
}
125+
126+
if !cap.matched {
127+
t.Fatal("last_lines arg was never bound — INSERT arg order drifted")
128+
}
129+
if len(cap.captured) == 0 {
130+
t.Fatal("worker bound an EMPTY last_lines value — the api consumer would " +
131+
"see no log tail")
132+
}
133+
134+
// CONSUMER logic (mirror of api/internal/models.GetDeploymentEvents): scan
135+
// the column into []byte, json.Unmarshal into []string. If the worker's
136+
// encoding ever changed, this unmarshal fails or yields the wrong slice.
137+
var consumerLines []string
138+
if err := json.Unmarshal(cap.captured, &consumerLines); err != nil {
139+
t.Fatalf("api consumer cannot unmarshal worker last_lines (%q): %v\n"+
140+
"PRODUCER↔CONSUMER SCHEMA DRIFT: the worker's upsertAutopsyRow "+
141+
"changed the last_lines encoding away from json.Marshal([]string); "+
142+
"GET /api/v1/deployments/:id/events would break.", string(cap.captured), err)
143+
}
144+
145+
if len(consumerLines) != len(producerLines) {
146+
t.Fatalf("last_lines parity: producer wrote %d lines, api consumer reads %d",
147+
len(producerLines), len(consumerLines))
148+
}
149+
for i := range producerLines {
150+
if consumerLines[i] != producerLines[i] {
151+
t.Errorf("last_lines[%d] parity: producer %q, consumer %q",
152+
i, producerLines[i], consumerLines[i])
153+
}
154+
}
155+
}
156+
157+
// TestAutopsySchemaParity_ColumnSetMatchesAPIConsumer asserts the worker
158+
// INSERTs exactly the column SET the api Events handler SELECTs. The worker's
159+
// INSERT statement is a literal in upsertAutopsyRow; this test pins that every
160+
// api-consumed column is bound (and in the documented order), so a column
161+
// rename on either side reds. We drive a real upsert and assert the INSERT
162+
// matched a regex naming each api-consumed column — a missing column would make
163+
// the regex fail to match and sqlmock would error with "call to ExecQuery ...
164+
// was not expected".
165+
func TestAutopsySchemaParity_ColumnSetMatchesAPIConsumer(t *testing.T) {
166+
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
167+
if err != nil {
168+
t.Fatalf("sqlmock.New: %v", err)
169+
}
170+
defer db.Close()
171+
172+
// A regex that requires every api-consumed column name to appear in the
173+
// INSERT's column list, in order. If the worker drops/renames a column the
174+
// api SELECTs, this regex won't match → sqlmock errors and the test reds.
175+
colRegex := `INSERT INTO deployment_events\s*\(\s*` +
176+
apiEventsColumns[0]
177+
for _, c := range apiEventsColumns[1:] {
178+
colRegex += `,\s*` + c
179+
}
180+
181+
mock.ExpectExec(colRegex).WillReturnResult(sqlmock.NewResult(0, 1))
182+
183+
if err := upsertAutopsyRow(context.Background(), db, uuid.New(),
184+
workerFailureReasonBuildFailed,
185+
sql.NullInt32{},
186+
"build failed",
187+
[]string{"a"},
188+
); err != nil {
189+
t.Fatalf("upsertAutopsyRow: %v (the INSERT column set drifted from the "+
190+
"api-consumed columns %v)", err, apiEventsColumns)
191+
}
192+
if err := mock.ExpectationsWereMet(); err != nil {
193+
t.Fatalf("unmet sqlmock expectations — INSERT column set drifted from the "+
194+
"api-consumed columns %v: %v", apiEventsColumns, err)
195+
}
196+
}

0 commit comments

Comments
 (0)