OUT-3731 | Tests: eligibility SQL unit tests + idempotency integration test - #1266
Conversation
Add a testcontainers-backed integration harness (jest.integration.config.ts + test/integration/*) that boots an ephemeral Postgres, applies the real migration history, and is hard-guarded to never truncate a non-local DB. Kept separate from the default `jest` unit run. - eligibility.integration.test.ts: exercises the real SQL — all six windows hit on their exact day, boundary misses, deleted/archived/completed exclusions, company single-row, and subtask carve-outs. - reminder-idempotency.integration.test.ts: one send → one ledger row; re-run → zero new (unique constraint); forced Copilot failure → ledger cleared + Sentry event. Fix surfaced by the real DB: the global softDelete Prisma extension rewrites .delete()/.deleteMany() into deletedAt updates for every model, but TaskReminderSents has no deletedAt — so ledger compensation silently failed and the unique constraint would block all future re-sends. Both compensation paths now hard-delete via $executeRaw; affected unit tests updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
testcontainers pulls in undici@7.x, which requires node >=20.18.1; the workflow's hardcoded 20.18.0 failed `yarn install` with an engine incompatibility. .nvmrc already pins 20.19.1, so point setup-node at it to match local dev and stop the drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Deployment failed with the following error: Learn More: https://vercel.link/multiple-function-regions |
Greptile SummaryThis PR introduces a real-Postgres integration test harness (testcontainers +
Confidence Score: 4/5The production code and test harness are well-constructed; the three issues found are confined to test infrastructure and a defensive SQL pattern, none of which affect the correctness of the reminder send or ledger deduplication. The core production paths — eligibility SQL, ledger-first insert, chunked batchTrigger, and both hard-delete compensation paths — are correct and thoroughly tested. The findings are limited to the test harness: a missing try/catch in globalSetup that could orphan a Docker container if migrations fail, an overly broad removeAllListeners call in setup-env, and the eligibility SELECT CASE lacking an ELSE clause that would make a future WHERE/CASE drift immediately visible rather than silently propagating a null reminderType. test/integration/globalSetup.ts (no container cleanup on migration failure) and src/jobs/notifications/eligibility.ts (SELECT CASE without ELSE). Important Files Changed
Sequence DiagramsequenceDiagram
participant Cron as send-task-reminders (cron)
participant DB as Postgres (TaskReminderSents)
participant Trigger as Trigger.dev
participant Dispatcher as dispatch-reminder-email
participant Copilot as Copilot API
participant Sentry as Sentry
Cron->>DB: getEligibleReminders() — raw SQL, 6 windows
DB-->>Cron: EligibilityRow[]
Cron->>Copilot: getWorkspace() per workspace
Cron->>Copilot: getCompanyClients() for company tasks (fan-out)
Cron->>DB: "createManyAndReturn (skipDuplicates=true) — ledger insert"
DB-->>Cron: inserted rows (deduped by unique constraint)
Cron->>Trigger: batchTrigger(chunk ≤500) — enqueue dispatchers
alt batchTrigger succeeds
Trigger->>Dispatcher: run(payload)
Dispatcher->>Copilot: createNotification()
Copilot-->>Dispatcher: "{ id }"
Dispatcher-->>Trigger: "{ ledgerId, notificationId, sent: true }"
else batchTrigger fails
Cron->>DB: "$executeRaw DELETE FROM TaskReminderSents WHERE id = ANY(ledgerIds)"
end
alt Dispatcher retries exhausted (onFailure)
Dispatcher->>Sentry: captureException(error, tags)
Dispatcher->>DB: "$executeRaw DELETE FROM TaskReminderSents WHERE id = ledgerId"
end
|
| const container = await new PostgreSqlContainer('postgres:16-alpine').start() | ||
| const url = container.getConnectionUri() | ||
|
|
||
| // `prisma migrate deploy` runs the real migration history (incl. CREATE EXTENSION ltree), | ||
| // so the schema matches prod exactly. dotenv won't override the env we pass explicitly, | ||
| // so the container URL wins over the dev .env DATABASE_URL/DIRECT_URL. | ||
| execSync('npx prisma migrate deploy', { | ||
| stdio: 'inherit', | ||
| env: { ...process.env, DATABASE_URL: url, DIRECT_URL: url }, | ||
| }) | ||
|
|
||
| writeFileSync(DB_URL_FILE, url, 'utf8') | ||
| process.env.DATABASE_URL = url | ||
| process.env.DIRECT_URL = url | ||
| ;(globalThis as unknown as { __PG__?: StartedPostgreSqlContainer }).__PG__ = container | ||
| } |
There was a problem hiding this comment.
Container orphaned on migration failure
If execSync('npx prisma migrate deploy', ...) throws (e.g. a failed migration, missing extension), the container has already been started but __PG__ is never assigned. globalTeardown then reads undefined from globalThis.__PG__ and calls container?.stop() — a no-op — so the container keeps running. testcontainers' Ryuk daemon handles this in most CI setups, but wrapping the exec + write in a try/catch that calls container.stop() before rethrowing is a cheaper guarantee than relying on the cleanup agent being present.
| } catch { | ||
| /* DBClient was never instantiated in this file */ | ||
| } | ||
| process.removeAllListeners('beforeExit') |
There was a problem hiding this comment.
process.removeAllListeners('beforeExit') removes every registered listener on that event, not only the one DBClient installs. Any future module that legitimately registers a beforeExit handler (e.g. for graceful shutdown or flushing diagnostics) would be silently stripped. Prefer removing only the specific listener if it is exported, or at minimum name the operation so it is easy to find and revisit.
| process.removeAllListeners('beforeExit') | |
| // Removes only the DBClient-installed beforeExit handler. If DBClient ever exports | |
| // the handler function, prefer `process.removeListener('beforeExit', handler)` here. | |
| process.removeAllListeners('beforeExit') |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
What
Real-Postgres tests for the reminder cron (OUT-3731). Stacked on
OUT-3738-observability(PR #1261) — the idempotency test asserts the Sentry capture added there — so review/merge that first.The ticket asked for "real DB, no mocks", but its cited reference actually mocks the DB and no real-DB test infra existed. So this PR introduces that harness (testcontainers) and the two suites on top of it.
New: integration test harness
jest.integration.config.ts+test/integration/*+ atest:integrationscript.globalSetupboots an ephemeralpostgres:16-alpine, runsprisma migrate deploy(the real migration history, so the schema matches prod), and publishes its URL;globalTeardownstops it.setup-envforces every worker'sDATABASE_URLonto the container (overriding the dev.env), and the test client is hard-guarded to refuse any non-local DB — a misconfig can't truncate a real database.yarn testviatestPathIgnorePatterns, so the DB-less unit suite still runs in CI. (Note: CI needs a Docker-capable job to runtest:integration— not added here.)Tests
eligibility.integration.test.ts(8): runs the actual SQL — all six windows hit on their exact day, ±1-day boundary misses, deleted/archived/completed exclusions, company → single company-level row, and the three subtask carve-outs (+ dead-parent variant). Dates anchored to the DB'sCURRENT_DATEto avoid UTC-midnight flake.reminder-idempotency.integration.test.ts(3): real DB, Copilot/Trigger/Sentry as doubles (batchTriggerfans out to the dispatcher inline so a forced failure exercises the realonFailure). One send → one ledger row; immediate re-run → 0 new (unique constraint); forced Copilot failure → ledger row cleared + tagged Sentry event.The real DB surfaced that ledger compensation never actually deleted. The global
softDelete/softDeleteManyPrisma extension onDBClientrewrites.delete()/.deleteMany()intodeletedAtupdates for every model, butTaskReminderSentshas nodeletedAtcolumn — so:dispatchReminderEmailOnFailureand the cron'sdispatchChunkcompensation silently failed,@@unique([taskId, recipientId, reminderType])constraint would then permanently block any re-send of a terminally-failed reminder.Both paths now hard-delete via
$executeRawto bypass the extension; the two unit tests that asserted the old.delete/.deleteManycalls were updated.Testing
test:integration→ 11/11 pass ·yarn test(notifications) → 25/25, integration excluded ·tsc --noEmitclean · eslint clean.🤖 Generated with Claude Code