|
| 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