Skip to content

fix(issue-143): add CHECK constraint enforcing AFTER_TTL ↔ cardTtlMinutes invariant#147

Merged
georgyia merged 5 commits into
mainfrom
fix/issue-143-cardttl-check-constraint
May 13, 2026
Merged

fix(issue-143): add CHECK constraint enforcing AFTER_TTL ↔ cardTtlMinutes invariant#147
georgyia merged 5 commits into
mainfrom
fix/issue-143-cardttl-check-constraint

Conversation

@georgyia

@georgyia georgyia commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #143. Follow-up to #89 / PR #136.

Summary

Adds a Postgres CHECK constraint on the User table so the database itself rejects any row where cancelPolicy = 'AFTER_TTL' but cardTtlMinutes is NULL or non-positive.

PR #136 closed the application-level loopholes (the Telegram menu now clears the TTL on policy switch, and the orchestrator falls back to IMMEDIATE cancellation if it ever encounters the broken state at checkout time). What was still missing was a database-level guard — any future code path that writes User directly (a script, a migration, a new endpoint, a typo in another module) could silently re-introduce the broken state.

This PR adds that belt-and-braces guard. The runtime fallback from PR #136 is intentionally untouched — the constraint is defense-in-depth, not a replacement.

Changes

  • prisma/migrations/20260503000000_add_user_after_ttl_check_constraint/migration.sql
    • Backfills any pre-existing AFTER_TTL rows missing a TTL with 60 minutes (the same default the Telegram menu uses), so the migration is safe to run on existing data instead of failing on a constraint violation.
    • Adds user_after_ttl_requires_cardttlminutes CHECK constraint:
      CHECK (
        \"cancelPolicy\" <> 'AFTER_TTL'
        OR (\"cardTtlMinutes\" IS NOT NULL AND \"cardTtlMinutes\" > 0)
      )
  • tests/integration/db/userAfterTtlConstraint.test.ts — new integration test that pins the constraint behaviour:
    • Rejects updates that switch to AFTER_TTL while cardTtlMinutes is NULL, that clear cardTtlMinutes on an AFTER_TTL user, and that set cardTtlMinutes to 0 on an AFTER_TTL user.
    • Rejects creates with AFTER_TTL + NULL cardTtlMinutes.
    • Allows AFTER_TTL with a positive TTL, all non-AFTER_TTL policies with NULL TTL, and atomic policy-and-TTL updates that keep the invariant satisfied.

schema.prisma is intentionally unchanged — Prisma 5 does not model CHECK constraints, so the migration is the canonical source.

Acceptance criteria (from #143)

  • New Prisma migration adds the CHECK constraint.
  • Migration is safe to run on existing rows (backfills any AFTER_TTL row missing a TTL with the 60-minute default).
  • An integration test asserts that prisma.user.update({ ... cancelPolicy: 'AFTER_TTL', cardTtlMinutes: null }) rejects with a constraint-violation error (plus the create path and the 0 / clear-TTL paths).
  • Runtime fallback from PR fix(issue-89): close AFTER_TTL cancel policy loopholes #136 is untouched — verified by inspection of src/orchestrator/intentService.ts and src/telegram/menuHandler.ts.

Test plan

docker compose up -d
npx prisma migrate deploy           # applies new migration cleanly
npx jest --testPathPattern=db/userAfterTtl --runInBand --forceExit
# → 7/7 pass (4 rejection cases + 3 acceptance cases)
npx jest --testPathPattern=unit --forceExit
# → 355/355 unit tests pass

Other pre-existing integration failures observed locally (e.g. tests/integration/stripe/reconciliation.test.ts, tracked in #88) are unrelated to this change and reproduce on main without this branch applied.

Summary by CodeRabbit

  • Bug Fixes

    • Enforced validation preventing an AFTER_TTL cancel policy from existing without a positive card TTL; existing affected accounts were backfilled with a default 60-minute card TTL so they comply.
    • Blocks creation or updates that would leave an AFTER_TTL policy without a valid card TTL.
  • Tests

    • Added integration tests ensuring invalid configurations are rejected and valid transitions remain accepted.

Review Change Stack

…utes

Adds a Postgres CHECK constraint so the database itself rejects any User
row where cancelPolicy = AFTER_TTL but cardTtlMinutes is NULL or non-positive.
This is defense-in-depth on top of the application-level handling added in
PR #136 — it closes the gap that any future code path writing User directly
(scripts, ad-hoc updates, new endpoints, typos in another module) could
re-introduce the broken state.

The migration backfills any existing AFTER_TTL rows missing a TTL with the
60-minute default already used by the Telegram menu, so the migration is
safe to run on existing data.

Closes #143
@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a983ea62-802a-4b4f-9ce7-964d235e7dcf

📥 Commits

Reviewing files that changed from the base of the PR and between 0a45a8a and 6fde9b0.

📒 Files selected for processing (1)
  • tests/integration/db/userAfterTtlConstraint.test.ts

📝 Walkthrough

Walkthrough

A database migration backfills User rows and adds a CHECK constraint user_after_ttl_requires_cardttlminutes so that when cancelPolicy = 'AFTER_TTL', cardTtlMinutes must be non-null and > 0. Integration tests verify rejected invalid states and accepted valid transitions.

Changes

Data Integrity Enforcement

Layer / File(s) Summary
Migration & Backfill
prisma/migrations/20260503000000_add_user_after_ttl_check_constraint/migration.sql
Backfills User rows where cancelPolicy = 'AFTER_TTL' and cardTtlMinutes is null or <= 0 to 60, then adds CHECK constraint user_after_ttl_requires_cardttlminutes requiring cardTtlMinutes IS NOT NULL AND > 0 when cancelPolicy = 'AFTER_TTL'.
Constraint Validation Tests
tests/integration/db/userAfterTtlConstraint.test.ts
Adds integration tests asserting Prisma create/update operations reject with the constraint for invalid AFTER_TTL/cardTtlMinutes combinations and accept valid states; includes helpers, created-row tracking, cleanup in afterAll, and Prisma disconnect.

Sequence Diagram(s)

(Skipped — change is a DB constraint + tests; does not introduce multi-component control flow requiring a sequence diagram.)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

"I hopped through rows and fixed each hole,
Card TTLs set so policies stay whole.
Constraints snug like a cozy burrow,
No nulls at dusk, no zeros tomorrow.
Hooray — the data's safe to follow!" 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a CHECK constraint to enforce the AFTER_TTL ↔ cardTtlMinutes invariant on the User table, which is the core purpose of both the migration and test additions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-143-cardttl-check-constraint

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@prisma/migrations/20260503000000_add_user_after_ttl_check_constraint/migration.sql`:
- Around line 16-19: The DB CHECK currently only enforces that WHEN
"cancelPolicy" = 'AFTER_TTL' then "cardTtlMinutes" must be non-null and >0;
update the constraint to make the relationship bidirectional so non-'AFTER_TTL'
rows cannot have a TTL either. Modify the CHECK in the migration that references
"cancelPolicy" and "cardTtlMinutes" so it requires either (cancelPolicy =
'AFTER_TTL' AND cardTtlMinutes IS NOT NULL AND cardTtlMinutes > 0) OR
(cancelPolicy <> 'AFTER_TTL' AND cardTtlMinutes IS NULL), matching the API
validation in src/api/routes/users.ts.

In `@tests/integration/db/userAfterTtlConstraint.test.ts`:
- Around line 94-104: Add create-path tests that assert constraint violations
when CardCancelPolicy is AFTER_TTL and cardTtlMinutes is non-positive: add at
least two cases calling prisma.user.create (like the existing test using
expectConstraintViolation) with cardTtlMinutes set to 0 and to a negative value,
keeping the same structure and using CardCancelPolicy.AFTER_TTL so the migration
rule is fully covered for create paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bf2164b0-9c80-45aa-b654-47746459100a

📥 Commits

Reviewing files that changed from the base of the PR and between 2c92df5 and 970bad5.

📒 Files selected for processing (2)
  • prisma/migrations/20260503000000_add_user_after_ttl_check_constraint/migration.sql
  • tests/integration/db/userAfterTtlConstraint.test.ts

Comment thread tests/integration/db/userAfterTtlConstraint.test.ts
📝 CodeRabbit Chat: Implement requested code changes

@JonasBaeumer JonasBaeumer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, please check the PR review comments and fix the CI issues before merge

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/integration/db/userAfterTtlConstraint.test.ts`:
- Around line 15-48: This test file uses the real Prisma client (prisma) and
performs live DB operations via createUser()/prisma.user.create,
expectConstraintViolation(await ...) assertions, and cleanup in afterAll
(prisma.user.deleteMany, prisma.$disconnect), violating the rule that *.test.ts
must mock external deps; replace the direct prisma import with a mocked Prisma
client and stub prisma.user.create, prisma.user.update, prisma.user.deleteMany
and prisma.$disconnect (or wrap createUser to call an injected client) so tests
run purely in-memory; keep the same helpers (createUser,
expectConstraintViolation, createdUserIds, CONSTRAINT_NAME) but have them
operate against the mocked methods and ensure createdUserIds is cleared between
tests rather than performing real deletes.
- Around line 119-156: The first test case inside the describe('accepts valid
states') block has an await call to createUser run directly in the describe
scope; wrap that block in an async it(...) (e.g. it('accepts valid AFTER_TTL
state', async () => { const user = await createUser({...}); expect(...); }));
keep the existing assertions and references to CardCancelPolicy and createUser
unchanged; also ensure subsequent async tests (which call prisma.user.update)
remain async. Finally, because these tests use a live Prisma client
(prisma.user.update/create), either mock Prisma in this test (replace real calls
with mocked implementations) or move the file to an integration test location
and rename it so it isn’t treated as a unit test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b1bb0334-1e39-4081-8e6d-78a22824e0a5

📥 Commits

Reviewing files that changed from the base of the PR and between 970bad5 and 2384591.

📒 Files selected for processing (1)
  • tests/integration/db/userAfterTtlConstraint.test.ts

Comment thread tests/integration/db/userAfterTtlConstraint.test.ts
Comment thread tests/integration/db/userAfterTtlConstraint.test.ts
Wrap the AFTER_TTL positive TTL case in an it() block so the integration test is syntactically valid and actually executes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
tests/integration/db/userAfterTtlConstraint.test.ts (1)

15-48: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

*.test.ts guideline conflict: this suite performs live DB I/O via Prisma.

Line 15 imports the real client, and Line 26/59/73/87/96/108/147 plus Line 45/47 execute real DB operations. That conflicts with the repository rule for **/*.test.ts files requiring mocked external dependencies and no database/network access.

As per coding guidelines, **/*.test.ts: Every module must have unit tests with mocked external dependencies and no database/network access.

Also applies to: 50-156

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/db/userAfterTtlConstraint.test.ts` around lines 15 - 48,
This test imports the real Prisma client (prisma) and performs live DB I/O in
createUser, expectConstraintViolation, and afterAll (prisma.user.create,
prisma.user.deleteMany, prisma.$disconnect), which violates the repo rule that
**/*.test.ts must mock external dependencies; replace the real client with a
mocked test double: update the import of prisma to a mocked version (or use
jest.mock/vi.mock) and stub prisma.user.create, prisma.user.deleteMany and
prisma.$disconnect to return controlled values/errors, refactor createUser to
call the mocked prisma, and ensure expectConstraintViolation asserts against the
mocked error containing CONSTRAINT_NAME; alternatively move this file to an
integration test folder and keep the real client if integration tests are
allowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/integration/db/userAfterTtlConstraint.test.ts`:
- Around line 119-154: Add a new acceptance test in
tests/integration/db/userAfterTtlConstraint.test.ts that covers the atomic
transition into AFTER_TTL with a positive TTL: create an initial user using
createUser with a non-AFTER_TTL policy and cardTtlMinutes null, then perform a
single prisma.user.update to set cancelPolicy to CardCancelPolicy.AFTER_TTL and
cardTtlMinutes to a positive integer, and assert the returned record has
cancelPolicy === CardCancelPolicy.AFTER_TTL and cardTtlMinutes === that integer;
place this alongside the existing tests that use createUser and
prisma.user.update so it mirrors the symmetric case already covered.

---

Duplicate comments:
In `@tests/integration/db/userAfterTtlConstraint.test.ts`:
- Around line 15-48: This test imports the real Prisma client (prisma) and
performs live DB I/O in createUser, expectConstraintViolation, and afterAll
(prisma.user.create, prisma.user.deleteMany, prisma.$disconnect), which violates
the repo rule that **/*.test.ts must mock external dependencies; replace the
real client with a mocked test double: update the import of prisma to a mocked
version (or use jest.mock/vi.mock) and stub prisma.user.create,
prisma.user.deleteMany and prisma.$disconnect to return controlled
values/errors, refactor createUser to call the mocked prisma, and ensure
expectConstraintViolation asserts against the mocked error containing
CONSTRAINT_NAME; alternatively move this file to an integration test folder and
keep the real client if integration tests are allowed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ce8a40ad-6c47-4e20-b85d-654abbc26d2b

📥 Commits

Reviewing files that changed from the base of the PR and between 2384591 and 0a45a8a.

📒 Files selected for processing (1)
  • tests/integration/db/userAfterTtlConstraint.test.ts

Comment thread tests/integration/db/userAfterTtlConstraint.test.ts
@georgyia georgyia requested a review from JonasBaeumer May 4, 2026 15:28
Symmetric to the existing 'switch away from AFTER_TTL' test, this asserts
the CHECK constraint accepts an atomic update that flips cancelPolicy to
AFTER_TTL while setting a positive cardTtlMinutes in the same statement.

Pins the intended behaviour against future migrations or trigger-based
rewrites that might evaluate the constraint mid-update.
@georgyia

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining CodeRabbit threads in 6fde9b0:

  • L154 (atomic switch to AFTER_TTL): added it('allows switching to AFTER_TTL with a positive cardTtlMinutes in a single update', ...) as the symmetric counterpart to the existing switch-away test.
  • L104 (create→0 case): already covered by it('rejects create with AFTER_TTL and cardTtlMinutes set to 0', ...) at L106–L116. Per the earlier thread, skipping the negative-value case (Prisma rejects negatives before the CHECK fires).
  • L48 (no-DB rule): false positive — file lives in tests/integration/db/ and is explicitly an integration test asserting a Postgres CHECK; the no-DB rule applies to tests/unit/.
  • L19 (bidirectional CHECK): keeping the unidirectional constraint per your earlier comment — runtime hazard scoped to AFTER_TTL + null TTL; stale cardTtlMinutes on other policies is already blocked at the API layer and out of scope here.

All four threads resolved. CI green: Lint, Type Check, Unit (392), Integration (incl. the new test, 51s), CodeQL.

@georgyia

Copy link
Copy Markdown
Collaborator Author

@JonasBaeumer this PR is ready from my side: approved, all checks green, and all review threads resolved. is blocked for me by the base-branch policy, so it needs a maintainer/admin merge when you have a minute.

@georgyia georgyia merged commit 479c7c8 into main May 13, 2026
7 checks passed
@georgyia georgyia deleted the fix/issue-143-cardttl-check-constraint branch May 13, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

db: add CHECK constraint enforcing AFTER_TTL ↔ cardTtlMinutes invariant

2 participants