@@ -380,6 +380,71 @@ func TestCaptureDeploymentAutopsy_LastLinesJSONRoundtrip(t *testing.T) {
380380 }
381381}
382382
383+ // TestEmitDeployFailedAudit_FirstTickInserts verifies that the first autopsy
384+ // tick for a deployment with no pre-existing deploy.failed audit row resolves
385+ // the team_id, runs the dedup EXISTS probe (false), then INSERTs the
386+ // deploy.failed audit row exactly once.
387+ func TestEmitDeployFailedAudit_FirstTickInserts (t * testing.T ) {
388+ db , mock , err := sqlmock .New (sqlmock .QueryMatcherOption (sqlmock .QueryMatcherRegexp ))
389+ if err != nil {
390+ t .Fatalf ("sqlmock.New: %v" , err )
391+ }
392+ defer db .Close ()
393+
394+ id := uuid .New ()
395+ teamID := uuid .New ()
396+
397+ mock .ExpectQuery (`SELECT team_id FROM deployments` ).
398+ WithArgs (id ).
399+ WillReturnRows (sqlmock .NewRows ([]string {"team_id" }).AddRow (teamID ))
400+ // Dedup probe: no existing deploy.failed row for this deployment.
401+ mock .ExpectQuery (`SELECT EXISTS` ).
402+ WithArgs (auditKindDeployFailed , id .String ()).
403+ WillReturnRows (sqlmock .NewRows ([]string {"exists" }).AddRow (false ))
404+ mock .ExpectExec (`INSERT INTO audit_log` ).
405+ WillReturnResult (sqlmock .NewResult (0 , 1 ))
406+
407+ if err := emitDeployFailedAudit (context .Background (), db , id , "ImagePullBackOff" , "manifest unknown" ); err != nil {
408+ t .Fatalf ("emitDeployFailedAudit (first tick): %v" , err )
409+ }
410+ if err := mock .ExpectationsWereMet (); err != nil {
411+ t .Errorf ("unmet sqlmock expectations: %v" , err )
412+ }
413+ }
414+
415+ // TestEmitDeployFailedAudit_SecondTickIsNoOp verifies the idempotency guard:
416+ // when a deploy.failed audit row already exists for the deployment (e.g. the
417+ // api emitted it synchronously, or a prior autopsy tick did), a subsequent
418+ // tick runs the dedup EXISTS probe (true) and performs NO INSERT — so the
419+ // email forwarder never sends a duplicate failure email per reconciler tick.
420+ func TestEmitDeployFailedAudit_SecondTickIsNoOp (t * testing.T ) {
421+ db , mock , err := sqlmock .New (sqlmock .QueryMatcherOption (sqlmock .QueryMatcherRegexp ))
422+ if err != nil {
423+ t .Fatalf ("sqlmock.New: %v" , err )
424+ }
425+ defer db .Close ()
426+
427+ id := uuid .New ()
428+ teamID := uuid .New ()
429+
430+ mock .ExpectQuery (`SELECT team_id FROM deployments` ).
431+ WithArgs (id ).
432+ WillReturnRows (sqlmock .NewRows ([]string {"team_id" }).AddRow (teamID ))
433+ // Dedup probe: a deploy.failed row already exists → emit must short-circuit.
434+ mock .ExpectQuery (`SELECT EXISTS` ).
435+ WithArgs (auditKindDeployFailed , id .String ()).
436+ WillReturnRows (sqlmock .NewRows ([]string {"exists" }).AddRow (true ))
437+ // NO ExpectExec(INSERT INTO audit_log) — a second INSERT would be an
438+ // unmet/unexpected expectation and fail the test.
439+
440+ if err := emitDeployFailedAudit (context .Background (), db , id , "ImagePullBackOff" , "manifest unknown" ); err != nil {
441+ t .Fatalf ("emitDeployFailedAudit (second tick): %v" , err )
442+ }
443+ if err := mock .ExpectationsWereMet (); err != nil {
444+ t .Errorf ("unmet sqlmock expectations (duplicate INSERT?): %v" , err )
445+ }
446+ }
447+
383448// ── fakeAutopsyK8s stub ───────────────────────────────────────────────────────
384449
385450type fakeAutopsyK8s struct {
0 commit comments