From e76e631866fdfa2f92a2677e92c41cde2e3d3471 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 16:44:37 +0200 Subject: [PATCH 1/3] fix(api/purchases): add scheduled_execution_at to GetPlannedExecutions SELECT GetPlannedExecutions projected 26 columns, but scanExecutionRows scans 27 (the 27th being scheduled_execution_at). Against a real PostgreSQL instance this caused rows.Scan to fail ("expected 27 destination arguments in Scan, not 26"), returning "failed to scan execution" and surfacing as a 500 on the Planned Purchases list endpoint. The regression drifted in via PR #804 (commit 8e5f13974) which added scheduled_execution_at to scanExecutionRows and every other SELECT feeding it, but skipped GetPlannedExecutions. Also tighten TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns: the mock ExpectQuery regexp now requires both idempotency_key AND scheduled_execution_at in the SQL so future column-count drift is caught at test time rather than in production. Closes #1247 --- internal/config/store_postgres.go | 2 +- internal/config/store_postgres_pgxmock_test.go | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 6f56d7552..81694fec6 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -1195,7 +1195,7 @@ func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []str created_by_user_id, retry_execution_id, retry_attempt_n, approval_token_expires_at, executed_by_user_id, executed_at, pre_approval_skip_reason, - idempotency_key + idempotency_key, scheduled_execution_at FROM purchase_executions WHERE status = ANY($1) ORDER BY scheduled_date ASC NULLS LAST, id ASC diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index cebe69d08..e788b6f94 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -687,8 +687,11 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { "idem-key-planned", sql.NullTime{}, // scheduled_execution_at (NULL: not on the pre-fire delay path) ) - // Regexp matcher: only matches if the issued SELECT projects idempotency_key. - mock.ExpectQuery("idempotency_key"). + // Regexp matcher: only matches if the issued SELECT projects both + // idempotency_key and scheduled_execution_at. The alternation forces both + // names to appear; a projection missing either column fails to match, the + // mock returns no rows, and the test catches the column-count drift. + mock.ExpectQuery(`idempotency_key.*scheduled_execution_at|scheduled_execution_at.*idempotency_key`). WithArgs(pgxmock.AnyArg(), pgxmock.AnyArg()). WillReturnRows(rows) From 041f790a7a7aaea0e40a370e874ac4abb8940c7c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 17:32:30 +0200 Subject: [PATCH 2/3] test(config): assert ScheduledExecutionAt is nil for NULL DB rows in GetPlannedExecutions test The regression test already verified the scan column count (select+scan both have 27 columns). This tightens it to also assert that a NULL scheduled_execution_at comes back as a nil *time.Time pointer rather than a zero-value time, confirming applyNullTimesToExecution's Valid-guard. --- internal/config/store_postgres_pgxmock_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index e788b6f94..d386d0c39 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -699,6 +699,9 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { require.NoError(t, err) require.Len(t, execs, 1) assert.Equal(t, "idem-key-planned", execs[0].IdempotencyKey) + // NULL scheduled_execution_at must deserialise as nil (*time.Time), not a zero + // value; applyNullTimesToExecution only sets the pointer when Valid is true. + assert.Nil(t, execs[0].ScheduledExecutionAt, "NULL scheduled_execution_at must be nil, not zero time") assert.NoError(t, mock.ExpectationsWereMet()) } From 5d954f2b67f34d27a7206564b6d20e488c717cac Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 10 Jul 2026 16:14:04 +0200 Subject: [PATCH 3/3] test(config): assert populated scheduled_execution_at round-trips in GetPlannedExecutions Extend TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns with a second row carrying a non-NULL scheduled_execution_at and assert it deserialises into the ScheduledExecutionAt pointer. The prior test only covered the NULL path (nil pointer), which passes even if a real value is silently dropped. This adds the direct regression guard for the fix: with the column absent from the SELECT projection the value never reaches ScheduledExecutionAt and every delayed execution reads back as unscheduled. --- .../config/store_postgres_pgxmock_test.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index d386d0c39..3f6482235 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -676,6 +676,7 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { "executed_by_user_id", "executed_at", "pre_approval_skip_reason", "idempotency_key", "scheduled_execution_at", } + schedAt := now.Add(2 * time.Hour) rows := pgxmock.NewRows(cols).AddRow( "plan-1", "exec-1", "pending", 1, now, sql.NullTime{}, "tok-123", recsJSON, @@ -686,6 +687,16 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { nil, sql.NullTime{}, nil, "idem-key-planned", sql.NullTime{}, // scheduled_execution_at (NULL: not on the pre-fire delay path) + ).AddRow( + "plan-1", "exec-2", "pending", 1, now, + sql.NullTime{}, "tok-456", recsJSON, + 100.0, 200.0, sql.NullTime{}, "", sql.NullTime{}, + nil, "", nil, nil, 100, + nil, nil, 0, + sql.NullTime{}, + nil, sql.NullTime{}, nil, + "idem-key-delayed", + sql.NullTime{Time: schedAt, Valid: true}, // scheduled_execution_at populated (pre-fire delay path) ) // Regexp matcher: only matches if the issued SELECT projects both // idempotency_key and scheduled_execution_at. The alternation forces both @@ -697,11 +708,17 @@ func TestPGXMock_GetPlannedExecutions_ProjectsAllScanColumns(t *testing.T) { execs, err := store.GetPlannedExecutions(ctx, []string{"pending"}, 10) require.NoError(t, err) - require.Len(t, execs, 1) + require.Len(t, execs, 2) assert.Equal(t, "idem-key-planned", execs[0].IdempotencyKey) // NULL scheduled_execution_at must deserialise as nil (*time.Time), not a zero // value; applyNullTimesToExecution only sets the pointer when Valid is true. assert.Nil(t, execs[0].ScheduledExecutionAt, "NULL scheduled_execution_at must be nil, not zero time") + // Non-NULL scheduled_execution_at must round-trip into the pointer field. This + // is the direct regression guard for the fix: with the column absent from the + // SELECT projection the value never reaches ScheduledExecutionAt and every + // delayed execution reads back as unscheduled. + require.NotNil(t, execs[1].ScheduledExecutionAt, "populated scheduled_execution_at must round-trip, not be dropped") + assert.Equal(t, schedAt, *execs[1].ScheduledExecutionAt) assert.NoError(t, mock.ExpectationsWereMet()) }