Skip to content

Commit fa6ae3f

Browse files
committed
fix(db): write canonical 'canceled' on all cancel paths + cleanup filter
All cancel write paths (CancelExecutionAtomic, CancelScheduledExecutionAtomic, CancelAllPendingExchanges, CancelPendingExchangesByOrigin) were still emitting status='cancelled' and the old cancelled_by column after PR #1277 added the expand-contract rename. TransitionRIExchangeStatus in handler_ri_exchange.go also used the legacy spelling. If #1278 narrows the CHECK before these writers switch, every cancel path fails with check_violation (SQLSTATE 23514). CleanupOldExecutions filtered only 'cancelled', so rows written by new code with status='canceled' were never terminal-cleaned. Fixes: - Switch all cancel SQL writes to StatusCanceled ("canceled") via $N params - Use canceled_by column in CancelExecutionAtomic + CancelScheduledExecutionAtomic - Add 'canceled' to CleanupOldExecutions terminal-status IN clause - Use config.StatusCanceled in rejectRIExchange handler (line ~1372 + response) - Add 8 pgxmock regression tests asserting canonical spelling per path - Update 4 test mocks that hard-coded "cancelled" as the reject-transition arg
1 parent 1d9f1b8 commit fa6ae3f

6 files changed

Lines changed: 188 additions & 41 deletions

File tree

‎internal/api/coverage_extras_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,9 @@ func TestHandler_rejectRIExchange_AlreadyProcessed(t *testing.T) {
331331
mockStore := new(MockConfigStore)
332332
mockStore.On("GetRIExchangeRecord", ctx, "11111111-1111-1111-1111-111111111111").Return(
333333
&config.RIExchangeRecord{ID: "11111111-1111-1111-1111-111111111111", ApprovalToken: "tok"}, nil)
334-
// Transition returns nil indicating already processed
335-
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled", mock.Anything).
334+
// Transition returns nil indicating already processed.
335+
// Canonical spelling used (#1277 follow-up).
336+
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", config.StatusCanceled, mock.Anything).
336337
Return(nil, nil)
337338

338339
h := &Handler{config: mockStore}

‎internal/api/handler_ri_exchange.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,15 +1369,15 @@ func (h *Handler) rejectRIExchange(ctx context.Context, id, token string) (any,
13691369
}
13701370

13711371
// Token-based rejection: no session user, so transitioned_by = NULL.
1372-
transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "cancelled", nil)
1372+
transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", config.StatusCanceled, nil)
13731373
if err != nil {
13741374
return nil, fmt.Errorf("failed to transition exchange status: %w", err)
13751375
}
13761376
if transitioned == nil {
1377-
return nil, NewClientError(409, "exchange already processed, expired, or was cancelled")
1377+
return nil, NewClientError(409, "exchange already processed, expired, or was canceled")
13781378
}
13791379

1380-
return map[string]string{"status": "cancelled"}, nil
1380+
return map[string]string{"status": config.StatusCanceled}, nil
13811381
}
13821382

13831383
// RIExchangeConfigResponse is the response for GET /api/ri-exchange/config.

‎internal/api/handler_ri_exchange_test.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,9 @@ func TestRejectRIExchange_AlreadyCompleted(t *testing.T) {
262262
ExchangeID: "exch-already-done",
263263
}, nil)
264264

265-
// Transition from pending→canceled fails (record is not pending)
266-
mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "cancelled", mock.Anything).
265+
// Transition from pending->canceled fails (record is not pending).
266+
// Uses canonical spelling; see config.StatusCanceled.
267+
mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", config.StatusCanceled, mock.Anything).
267268
Return((*config.RIExchangeRecord)(nil), nil)
268269

269270
_, err := h.rejectRIExchange(ctx, id, token)
@@ -1577,10 +1578,10 @@ func TestRejectRIExchange_TokenPathActorIsNil(t *testing.T) {
15771578
mockStore.On("GetRIExchangeRecord", ctx, id).Return(&config.RIExchangeRecord{
15781579
ID: id, Status: "pending", ApprovalToken: "tok",
15791580
}, nil)
1580-
// Token path: actor must be nil.
1581-
mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", "cancelled",
1581+
// Token path: actor must be nil. Canonical spelling used (#1277 follow-up).
1582+
mockStore.On("TransitionRIExchangeStatus", ctx, id, "pending", config.StatusCanceled,
15821583
(*string)(nil),
1583-
).Return(&config.RIExchangeRecord{ID: id, Status: "cancelled"}, nil)
1584+
).Return(&config.RIExchangeRecord{ID: id, Status: config.StatusCanceled}, nil)
15841585

15851586
_, err := (&Handler{config: mockStore}).rejectRIExchange(ctx, id, "tok")
15861587
require.NoError(t, err)

‎internal/api/router_handlers_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -784,18 +784,18 @@ func TestHandler_rejectRIExchange_ValidTokenAndRecord(t *testing.T) {
784784
ApprovalToken: "tok",
785785
Status: "pending",
786786
}, nil)
787-
// rejectRIExchange transitions to "cancelled" (UK spelling, matches DB canonical)
788-
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", "cancelled", mock.Anything).
787+
// rejectRIExchange transitions to "canceled" (US canonical spelling, #1277 follow-up).
788+
mockStore.On("TransitionRIExchangeStatus", ctx, "11111111-1111-1111-1111-111111111111", "pending", config.StatusCanceled, mock.Anything).
789789
Return(&config.RIExchangeRecord{
790790
ID: "11111111-1111-1111-1111-111111111111",
791-
Status: "cancelled",
791+
Status: config.StatusCanceled,
792792
}, nil)
793793

794794
h := &Handler{config: mockStore}
795795
result, err := h.rejectRIExchange(ctx, "11111111-1111-1111-1111-111111111111", "tok")
796796
require.NoError(t, err)
797797
m := result.(map[string]string)
798-
assert.Equal(t, "cancelled", m["status"])
798+
assert.Equal(t, config.StatusCanceled, m["status"])
799799
}
800800

801801
// ---------------------------------------------------------------------------

‎internal/config/store_postgres.go‎

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,12 +1044,13 @@ func (s *PostgresStore) SetCancelledBy(ctx context.Context, executionID, cancell
10441044
}
10451045

10461046
// CancelExecutionAtomic atomically transitions an execution from
1047-
// pending or notified to 'cancelled', setting cancelled_by to the supplied
1048-
// actor (NULL when actor is nil). The UPDATE is conditional on
1047+
// pending or notified to 'canceled' (canonical US spelling), setting
1048+
// canceled_by to the supplied actor (NULL when actor is nil). The UPDATE
1049+
// is conditional on
10491050
// status IN ('pending','notified') so a concurrent approve that has
10501051
// already transitioned the row to 'approved' causes zero rows to be
10511052
// affected and the method returns (false, currentStatus, nil) with the
1052-
// live status fetched via a follow-up SELECT. Returns (true, "cancelled",
1053+
// live status fetched via a follow-up SELECT. Returns (true, "canceled",
10531054
// nil) on success and (false, "", err) on a real DB error.
10541055
//
10551056
// The 'scheduled' status is intentionally NOT accepted here -- the
@@ -1064,16 +1065,17 @@ func (s *PostgresStore) SetCancelledBy(ctx context.Context, executionID, cancell
10641065
// status guard is inside the UPDATE rather than checked optimistically
10651066
// before entering the tx.
10661067
func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error) {
1068+
// StatusCanceled is the canonical US-spelling value; see types.go.
10671069
q := `
10681070
UPDATE purchase_executions
1069-
SET status = 'cancelled',
1070-
cancelled_by = $2,
1071+
SET status = $3,
1072+
canceled_by = $2,
10711073
updated_at = NOW()
10721074
WHERE execution_id = $1
10731075
AND status IN ('pending', 'notified')
10741076
RETURNING status
10751077
`
1076-
rows, err := tx.Query(ctx, q, executionID, cancelledBy)
1078+
rows, err := tx.Query(ctx, q, executionID, cancelledBy, StatusCanceled)
10771079
if err != nil {
10781080
return false, "", fmt.Errorf("failed to cancel execution: %w", err)
10791081
}
@@ -1104,11 +1106,11 @@ func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, ex
11041106
}
11051107

11061108
// CancelScheduledExecutionAtomic atomically transitions an execution from
1107-
// 'scheduled' to 'cancelled', setting cancelled_by to the supplied actor
1108-
// (NULL when actor is nil). Used by the Gmail-style pre-fire delay revoke
1109-
// path (issue #290 / #291 wave-2): an approved-but-not-yet-fired execution
1110-
// can be revoked at $0 by flipping it to cancelled before the scheduler
1111-
// fires the cloud SDK call.
1109+
// 'scheduled' to 'canceled' (canonical US spelling), setting canceled_by to
1110+
// the supplied actor (NULL when actor is nil). Used by the Gmail-style
1111+
// pre-fire delay revoke path (issue #290 / #291 wave-2): an
1112+
// approved-but-not-yet-fired execution can be revoked at $0 by flipping it
1113+
// to canceled before the scheduler fires the cloud SDK call.
11121114
//
11131115
// The 'scheduled' status is the only accepted source. A concurrent
11141116
// scheduler tick that already transitioned the row to 'approved' or
@@ -1117,20 +1119,21 @@ func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, ex
11171119
// ("revocation window has closed") so the frontend can fall through to
11181120
// the post-execution Azure direct-cancel API path.
11191121
//
1120-
// Returns (true, "cancelled", nil) on success and (false, "", err) on a
1122+
// Returns (true, StatusCanceled, nil) on success and (false, "", err) on a
11211123
// real DB error. Must be called inside a WithTx block so the suppression
11221124
// cleanup commits atomically with the status flip.
11231125
func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error) {
1126+
// StatusCanceled is the canonical US-spelling value; see types.go.
11241127
q := `
11251128
UPDATE purchase_executions
1126-
SET status = 'cancelled',
1127-
cancelled_by = $2,
1129+
SET status = $3,
1130+
canceled_by = $2,
11281131
updated_at = NOW()
11291132
WHERE execution_id = $1
11301133
AND status = 'scheduled'
11311134
RETURNING status
11321135
`
1133-
rows, err := tx.Query(ctx, q, executionID, cancelledBy)
1136+
rows, err := tx.Query(ctx, q, executionID, cancelledBy, StatusCanceled)
11341137
if err != nil {
11351138
return false, "", fmt.Errorf("failed to cancel scheduled execution: %w", err)
11361139
}
@@ -1598,9 +1601,11 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha
15981601
// Two independent cleanup branches, each with its own retention window so
15991602
// that a row far in one dimension doesn't block cleanup in the other:
16001603
//
1601-
// 1. Terminal-state cleanup: `status IN ('completed', 'cancelled') AND
1602-
// scheduled_date < NOW() - retention`. Keeps recent completions
1603-
// visible in the UI for at least `retention` days before purging.
1604+
// 1. Terminal-state cleanup: `status IN ('completed', 'cancelled', 'canceled') AND
1605+
// scheduled_date < NOW() - retention`. Both spellings are included to
1606+
// cover legacy rows ('cancelled') and new code rows ('canceled', canonical
1607+
// US spelling per StatusCanceled / migration 000089). Keeps recent
1608+
// completions visible in the UI for at least `retention` days before purging.
16041609
//
16051610
// 2. Expired-execution cleanup: `expires_at IS NOT NULL AND expires_at <
16061611
// NOW() - retention`. A row whose approval token has been expired
@@ -1620,10 +1625,14 @@ func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]Purcha
16201625
// NULL `expires_at` is excluded from branch 2 so rows that never had an
16211626
// expiration deadline are safe from expiry-based cleanup.
16221627
func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays int) (int64, error) {
1628+
// Both spellings are included: 'cancelled' (legacy, pre-migration 000089
1629+
// rows) and 'canceled' (canonical, written by new code after the
1630+
// follow-up to #1277). #1278 will drop 'cancelled' once all rows are
1631+
// normalized.
16231632
query := `
16241633
DELETE FROM purchase_executions
16251634
WHERE (
1626-
status IN ('completed', 'cancelled')
1635+
status IN ('completed', 'cancelled', 'canceled')
16271636
AND scheduled_date < NOW() - INTERVAL '1 day' * $1
16281637
)
16291638
OR (
@@ -2585,11 +2594,11 @@ func (s *PostgresStore) GetRIExchangeDailySpend(ctx context.Context, date time.T
25852594
func (s *PostgresStore) CancelAllPendingExchanges(ctx context.Context) (int64, error) {
25862595
query := `
25872596
UPDATE ri_exchange_history
2588-
SET status = 'cancelled'
2597+
SET status = $1, updated_at = NOW()
25892598
WHERE status = 'pending'
25902599
`
25912600

2592-
result, err := s.db.Exec(ctx, query)
2601+
result, err := s.db.Exec(ctx, query, StatusCanceled)
25932602
if err != nil {
25942603
return 0, fmt.Errorf("failed to cancel pending exchanges: %w", err)
25952604
}
@@ -2622,20 +2631,20 @@ func (s *PostgresStore) CancelPendingExchangesByOrigin(ctx context.Context, orig
26222631
case common.ExchangeOriginLadder:
26232632
query = `
26242633
UPDATE ri_exchange_history
2625-
SET status = 'cancelled', updated_at = NOW()
2634+
SET status = $1, updated_at = NOW()
26262635
WHERE status = 'pending'
26272636
AND ladder_run_id IS NOT NULL
26282637
`
26292638
default: // common.ExchangeOriginStandalone (validated non-unknown above)
26302639
query = `
26312640
UPDATE ri_exchange_history
2632-
SET status = 'cancelled', updated_at = NOW()
2641+
SET status = $1, updated_at = NOW()
26332642
WHERE status = 'pending'
26342643
AND ladder_run_id IS NULL
26352644
`
26362645
}
26372646

2638-
result, err := s.db.Exec(ctx, query)
2647+
result, err := s.db.Exec(ctx, query, StatusCanceled)
26392648
if err != nil {
26402649
return 0, fmt.Errorf("failed to cancel pending exchanges by origin %q: %w", origin, err)
26412650
}

‎internal/config/store_postgres_pgxmock_test.go‎

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1968,7 +1968,8 @@ func TestPGXMock_CancelAllPendingExchanges_Success(t *testing.T) {
19681968
store := storeWith(mock)
19691969
ctx := context.Background()
19701970

1971-
mock.ExpectExec("UPDATE").WillReturnResult(pgxmock.NewResult("UPDATE", 3))
1971+
// Now parameterized: $1 = status value.
1972+
mock.ExpectExec("UPDATE").WithArgs(pgxmock.AnyArg()).WillReturnResult(pgxmock.NewResult("UPDATE", 3))
19721973

19731974
n, err := store.CancelAllPendingExchanges(ctx)
19741975
require.NoError(t, err)
@@ -1987,7 +1988,8 @@ func TestPGXMock_CancelPendingExchangesByOrigin_Standalone(t *testing.T) {
19871988
ctx := context.Background()
19881989

19891990
// Standalone origin must target WHERE ladder_run_id IS NULL only.
1990-
mock.ExpectExec("ladder_run_id IS NULL").WillReturnResult(pgxmock.NewResult("UPDATE", 2))
1991+
// Now parameterized: $1 = status value.
1992+
mock.ExpectExec("ladder_run_id IS NULL").WithArgs(pgxmock.AnyArg()).WillReturnResult(pgxmock.NewResult("UPDATE", 2))
19911993

19921994
n, err := store.CancelPendingExchangesByOrigin(ctx, common.ExchangeOriginStandalone)
19931995
require.NoError(t, err)
@@ -2001,7 +2003,8 @@ func TestPGXMock_CancelPendingExchangesByOrigin_Ladder(t *testing.T) {
20012003
ctx := context.Background()
20022004

20032005
// Ladder origin must target WHERE ladder_run_id IS NOT NULL only.
2004-
mock.ExpectExec("ladder_run_id IS NOT NULL").WillReturnResult(pgxmock.NewResult("UPDATE", 1))
2006+
// Now parameterized: $1 = status value.
2007+
mock.ExpectExec("ladder_run_id IS NOT NULL").WithArgs(pgxmock.AnyArg()).WillReturnResult(pgxmock.NewResult("UPDATE", 1))
20052008

20062009
n, err := store.CancelPendingExchangesByOrigin(ctx, common.ExchangeOriginLadder)
20072010
require.NoError(t, err)
@@ -2521,3 +2524,136 @@ func TestPGXMock_GetExecutionByPlanAndDate_NotFoundWrapsErrNotFound(t *testing.T
25212524
"zero-row GetExecutionByPlanAndDate must wrap ErrNotFound so getOrCreateExecution can create a new execution; got: %v", err)
25222525
assert.NoError(t, mock.ExpectationsWereMet())
25232526
}
2527+
2528+
// ─── Canonical-cancel write regression tests (#1277 follow-up) ───────────────
2529+
//
2530+
// These tests assert that every cancel write path emits the canonical US
2531+
// spelling 'canceled' (StatusCanceled) rather than the legacy 'cancelled'.
2532+
// They fail on the pre-fix code (which hardcoded 'cancelled') and pass only
2533+
// when the parameterized $N arg receives StatusCanceled = "canceled".
2534+
2535+
// TestPGXMock_CancelExecutionAtomic_WritesCanonicalStatus verifies that
2536+
// CancelExecutionAtomic sends StatusCanceled ("canceled") as the status
2537+
// parameter and sets canceled_by (not cancelled_by).
2538+
// Regression guard for the #1277 follow-up fix.
2539+
func TestPGXMock_CancelExecutionAtomic_WritesCanonicalStatus(t *testing.T) {
2540+
mock := newMock(t)
2541+
store := storeWith(mock)
2542+
ctx := context.Background()
2543+
2544+
actor := "user@example.com"
2545+
2546+
// The mock uses regexp matching. We pin on:
2547+
// - "canceled_by" to confirm the new column is targeted (not cancelled_by)
2548+
// - WithArgs: $1=execID, $2=&actor, $3=StatusCanceled
2549+
// The RETURNING row also carries "canceled" so the caller's returned
2550+
// status is the canonical spelling end-to-end.
2551+
rows := pgxmock.NewRows([]string{"status"}).AddRow(StatusCanceled)
2552+
mock.ExpectQuery("canceled_by").
2553+
WithArgs("exec-1", &actor, StatusCanceled).
2554+
WillReturnRows(rows)
2555+
2556+
// Pass mock directly as pgx.Tx (PgxPoolIface embeds pgx.Tx).
2557+
ok, status, err := store.CancelExecutionAtomic(ctx, mock, "exec-1", &actor)
2558+
require.NoError(t, err)
2559+
assert.True(t, ok, "row should have been updated")
2560+
assert.Equal(t, StatusCanceled, status, "returned status must be canonical 'canceled'")
2561+
assert.NoError(t, mock.ExpectationsWereMet())
2562+
}
2563+
2564+
// TestPGXMock_CancelScheduledExecutionAtomic_WritesCanonicalStatus mirrors the
2565+
// above for the scheduled-execution cancel path.
2566+
func TestPGXMock_CancelScheduledExecutionAtomic_WritesCanonicalStatus(t *testing.T) {
2567+
mock := newMock(t)
2568+
store := storeWith(mock)
2569+
ctx := context.Background()
2570+
2571+
actor := "scheduler@system"
2572+
2573+
rows := pgxmock.NewRows([]string{"status"}).AddRow(StatusCanceled)
2574+
mock.ExpectQuery("canceled_by").
2575+
WithArgs("exec-sched", &actor, StatusCanceled).
2576+
WillReturnRows(rows)
2577+
2578+
ok, status, err := store.CancelScheduledExecutionAtomic(ctx, mock, "exec-sched", &actor)
2579+
require.NoError(t, err)
2580+
assert.True(t, ok, "row should have been updated")
2581+
assert.Equal(t, StatusCanceled, status, "returned status must be canonical 'canceled'")
2582+
assert.NoError(t, mock.ExpectationsWereMet())
2583+
}
2584+
2585+
// TestPGXMock_CancelAllPendingExchanges_WritesCanonicalStatus pins
2586+
// CancelAllPendingExchanges to emit StatusCanceled as the $1 argument.
2587+
// A pre-fix version of the code sent no parameter (the value was inlined as
2588+
// the literal 'cancelled'), so this test double-checks that the parameterized
2589+
// form is used and the value is the canonical spelling.
2590+
func TestPGXMock_CancelAllPendingExchanges_WritesCanonicalStatus(t *testing.T) {
2591+
mock := newMock(t)
2592+
store := storeWith(mock)
2593+
ctx := context.Background()
2594+
2595+
mock.ExpectExec("UPDATE ri_exchange_history").
2596+
WithArgs(StatusCanceled).
2597+
WillReturnResult(pgxmock.NewResult("UPDATE", 2))
2598+
2599+
n, err := store.CancelAllPendingExchanges(ctx)
2600+
require.NoError(t, err)
2601+
assert.Equal(t, int64(2), n)
2602+
assert.NoError(t, mock.ExpectationsWereMet())
2603+
}
2604+
2605+
// TestPGXMock_CancelPendingExchangesByOrigin_Standalone_WritesCanonicalStatus
2606+
// asserts the standalone branch passes StatusCanceled.
2607+
func TestPGXMock_CancelPendingExchangesByOrigin_Standalone_WritesCanonicalStatus(t *testing.T) {
2608+
mock := newMock(t)
2609+
store := storeWith(mock)
2610+
ctx := context.Background()
2611+
2612+
mock.ExpectExec("ladder_run_id IS NULL").
2613+
WithArgs(StatusCanceled).
2614+
WillReturnResult(pgxmock.NewResult("UPDATE", 1))
2615+
2616+
n, err := store.CancelPendingExchangesByOrigin(ctx, common.ExchangeOriginStandalone)
2617+
require.NoError(t, err)
2618+
assert.Equal(t, int64(1), n)
2619+
assert.NoError(t, mock.ExpectationsWereMet())
2620+
}
2621+
2622+
// TestPGXMock_CancelPendingExchangesByOrigin_Ladder_WritesCanonicalStatus
2623+
// asserts the ladder branch passes StatusCanceled.
2624+
func TestPGXMock_CancelPendingExchangesByOrigin_Ladder_WritesCanonicalStatus(t *testing.T) {
2625+
mock := newMock(t)
2626+
store := storeWith(mock)
2627+
ctx := context.Background()
2628+
2629+
mock.ExpectExec("ladder_run_id IS NOT NULL").
2630+
WithArgs(StatusCanceled).
2631+
WillReturnResult(pgxmock.NewResult("UPDATE", 3))
2632+
2633+
n, err := store.CancelPendingExchangesByOrigin(ctx, common.ExchangeOriginLadder)
2634+
require.NoError(t, err)
2635+
assert.Equal(t, int64(3), n)
2636+
assert.NoError(t, mock.ExpectationsWereMet())
2637+
}
2638+
2639+
// TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus verifies that the
2640+
// cleanup DELETE includes 'canceled' (canonical) in the terminal-status filter,
2641+
// so Plans-canceled rows written by new code are not missed.
2642+
// Regression guard for the #1277 follow-up Defect 2 fix.
2643+
func TestPGXMock_CleanupOldExecutions_IncludesCanonicalStatus(t *testing.T) {
2644+
mock := newMock(t)
2645+
store := storeWith(mock)
2646+
ctx := context.Background()
2647+
2648+
// The regex pins on both 'cancelled' (legacy) and 'canceled' (canonical)
2649+
// appearing together in the SQL. If either is missing the mock won't
2650+
// match, causing an "unexpected query" error from ExpectationsWereMet.
2651+
mock.ExpectExec(`'cancelled'.*'canceled'`).
2652+
WithArgs(pgxmock.AnyArg()).
2653+
WillReturnResult(pgxmock.NewResult("DELETE", 4))
2654+
2655+
n, err := store.CleanupOldExecutions(ctx, 90)
2656+
require.NoError(t, err)
2657+
assert.Equal(t, int64(4), n)
2658+
assert.NoError(t, mock.ExpectationsWereMet())
2659+
}

0 commit comments

Comments
 (0)