create-ailk 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +9 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +98 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +184 -11
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +77 -16
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +10 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +315 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +31 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +31 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file offerings-checkout-session.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the Offerings Checkout session record's table (#8930,
|
|
5
|
+
* spec docs/specs/8930-the-offerings-checkout-session-record-spec.md D1, D2,
|
|
6
|
+
* AC-1, AC-2, AC-3; contract A11). Requires DATABASE_URL pointing at a Postgres
|
|
7
|
+
* with the migrations applied — SKIPS when DATABASE_URL is unset (the repo
|
|
8
|
+
* convention; the `apps/api Route Tests (Postgres)` workflow exercises it).
|
|
9
|
+
*
|
|
10
|
+
* Uses the `pg` driver directly, in ONE transaction that is rolled back, so no
|
|
11
|
+
* row outlives the suite. Each refusal runs in its own savepoint and names the
|
|
12
|
+
* constraint Postgres reports, so a refusal for the wrong reason fails.
|
|
13
|
+
*/
|
|
14
|
+
import { idRegex } from "@working-theory/ids";
|
|
15
|
+
import { parseLookupKey } from "@working-theory/validation";
|
|
16
|
+
import { Client } from "pg";
|
|
17
|
+
|
|
18
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
19
|
+
|
|
20
|
+
const TABLE = "offerings_checkout_sessions";
|
|
21
|
+
const ORG = "org_01920000-8930-7000-8000-0000000000a1";
|
|
22
|
+
const WS = "wksp_01920000-8930-7000-8000-0000000000b1";
|
|
23
|
+
|
|
24
|
+
/** The one valid row every refusal changes a single column of. */
|
|
25
|
+
const VALID: Record<string, unknown> = {
|
|
26
|
+
session_id: "cs_test_a1",
|
|
27
|
+
workspace_id: WS,
|
|
28
|
+
organization_id: ORG,
|
|
29
|
+
price_id: "price_1Abc",
|
|
30
|
+
lookup_key: "growth_monthly",
|
|
31
|
+
mode: "test",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The eight CHECKs D2 names, and nothing else. */
|
|
35
|
+
const CHECKS = [
|
|
36
|
+
"offerings_checkout_sessions_id_format",
|
|
37
|
+
"offerings_checkout_sessions_session_id_format",
|
|
38
|
+
"offerings_checkout_sessions_workspace_id_format",
|
|
39
|
+
"offerings_checkout_sessions_organization_id_format",
|
|
40
|
+
"offerings_checkout_sessions_price_id_format",
|
|
41
|
+
"offerings_checkout_sessions_lookup_key_format",
|
|
42
|
+
"offerings_checkout_sessions_mode_domain",
|
|
43
|
+
"offerings_checkout_sessions_session_mode_agree",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
describeDb(
|
|
47
|
+
"the Offerings Checkout session record (#8930 D1, D2, live DB)",
|
|
48
|
+
() => {
|
|
49
|
+
let db: Client;
|
|
50
|
+
|
|
51
|
+
beforeAll(async () => {
|
|
52
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
53
|
+
await db.connect();
|
|
54
|
+
await db.query("BEGIN");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await db.query("ROLLBACK");
|
|
59
|
+
await db.end();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** Run `run` inside a savepoint; resolve to the violated constraint's name, or null. */
|
|
63
|
+
const violation = async (
|
|
64
|
+
run: () => Promise<unknown>,
|
|
65
|
+
): Promise<string | null> => {
|
|
66
|
+
await db.query("SAVEPOINT attempt");
|
|
67
|
+
try {
|
|
68
|
+
await run();
|
|
69
|
+
await db.query("ROLLBACK TO SAVEPOINT attempt");
|
|
70
|
+
return null;
|
|
71
|
+
} catch (err) {
|
|
72
|
+
await db.query("ROLLBACK TO SAVEPOINT attempt");
|
|
73
|
+
return (err as { constraint?: string }).constraint ?? String(err);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Insert one record: the valid row with `cols` over it. */
|
|
78
|
+
const insert = (cols: Record<string, unknown> = {}) => {
|
|
79
|
+
const all = { ...VALID, ...cols };
|
|
80
|
+
const names = Object.keys(all);
|
|
81
|
+
return db.query(
|
|
82
|
+
`INSERT INTO "${TABLE}" (${names.map((n) => `"${n}"`).join(", ")})
|
|
83
|
+
VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
|
|
84
|
+
names.map((n) => all[n]),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe("AC-1 — exactly the record's columns", () => {
|
|
89
|
+
it("has the eight columns, each NOT NULL", async () => {
|
|
90
|
+
const { rows } = await db.query(
|
|
91
|
+
`SELECT column_name, is_nullable FROM information_schema.columns
|
|
92
|
+
WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`,
|
|
93
|
+
[TABLE],
|
|
94
|
+
);
|
|
95
|
+
expect(rows.map((r) => r.column_name)).toEqual([
|
|
96
|
+
"created_at",
|
|
97
|
+
"id",
|
|
98
|
+
"lookup_key",
|
|
99
|
+
"mode",
|
|
100
|
+
"organization_id",
|
|
101
|
+
"price_id",
|
|
102
|
+
"session_id",
|
|
103
|
+
"workspace_id",
|
|
104
|
+
]);
|
|
105
|
+
expect(rows.filter((r) => r.is_nullable !== "NO")).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("carries exactly D2's eight CHECKs", async () => {
|
|
109
|
+
const { rows } = await db.query(
|
|
110
|
+
`SELECT conname FROM pg_constraint
|
|
111
|
+
WHERE conrelid = $1::regclass AND contype = 'c' ORDER BY conname`,
|
|
112
|
+
[TABLE],
|
|
113
|
+
);
|
|
114
|
+
expect(rows.map((r) => r.conname)).toEqual([...CHECKS].sort());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("has one non-primary index: the unique session id key", async () => {
|
|
118
|
+
const { rows } = await db.query(
|
|
119
|
+
`SELECT i.relname AS name, x.indisunique AS unique FROM pg_index x
|
|
120
|
+
JOIN pg_class i ON i.oid = x.indexrelid
|
|
121
|
+
WHERE x.indrelid = $1::regclass AND NOT x.indisprimary`,
|
|
122
|
+
[TABLE],
|
|
123
|
+
);
|
|
124
|
+
expect(rows).toEqual([
|
|
125
|
+
{ name: "offerings_checkout_sessions_session_id_key", unique: true },
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("AC-2 — Postgres refuses each malformed row by the named constraint", () => {
|
|
131
|
+
it("accepts the valid row and mints a prefixed id", async () => {
|
|
132
|
+
await db.query("SAVEPOINT ok");
|
|
133
|
+
const { rows } = await insert();
|
|
134
|
+
expect(rows[0].id).toMatch(idRegex("offeringsCheckoutSession"));
|
|
135
|
+
await db.query("ROLLBACK TO SAVEPOINT ok");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("accepts a live record of a live session", async () => {
|
|
139
|
+
expect(
|
|
140
|
+
await violation(() =>
|
|
141
|
+
insert({ session_id: "cs_live_a1", mode: "live" }),
|
|
142
|
+
),
|
|
143
|
+
).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it.each([
|
|
147
|
+
[
|
|
148
|
+
"a charge id",
|
|
149
|
+
{ session_id: "ch_test_a1" },
|
|
150
|
+
"offerings_checkout_sessions_session_id_format",
|
|
151
|
+
],
|
|
152
|
+
[
|
|
153
|
+
"a session id with no body",
|
|
154
|
+
{ session_id: "cs_test_" },
|
|
155
|
+
"offerings_checkout_sessions_session_id_format",
|
|
156
|
+
],
|
|
157
|
+
[
|
|
158
|
+
"a session id with a dash",
|
|
159
|
+
{ session_id: "cs_test_a-1" },
|
|
160
|
+
"offerings_checkout_sessions_session_id_format",
|
|
161
|
+
],
|
|
162
|
+
[
|
|
163
|
+
"a workspace id of another shape",
|
|
164
|
+
{ workspace_id: "ws_1" },
|
|
165
|
+
"offerings_checkout_sessions_workspace_id_format",
|
|
166
|
+
],
|
|
167
|
+
[
|
|
168
|
+
"an organization id of another shape",
|
|
169
|
+
{ organization_id: "org_1" },
|
|
170
|
+
"offerings_checkout_sessions_organization_id_format",
|
|
171
|
+
],
|
|
172
|
+
[
|
|
173
|
+
"an empty Price id",
|
|
174
|
+
{ price_id: "" },
|
|
175
|
+
"offerings_checkout_sessions_price_id_format",
|
|
176
|
+
],
|
|
177
|
+
[
|
|
178
|
+
"a Price id with a space",
|
|
179
|
+
{ price_id: "price 1" },
|
|
180
|
+
"offerings_checkout_sessions_price_id_format",
|
|
181
|
+
],
|
|
182
|
+
[
|
|
183
|
+
"an uppercase lookup key",
|
|
184
|
+
{ lookup_key: "Growth_Monthly" },
|
|
185
|
+
"offerings_checkout_sessions_lookup_key_format",
|
|
186
|
+
],
|
|
187
|
+
[
|
|
188
|
+
"a 41-character plan inside the column width",
|
|
189
|
+
{ lookup_key: `${"a".repeat(41)}_once` },
|
|
190
|
+
"offerings_checkout_sessions_lookup_key_format",
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
"a mode of prod",
|
|
194
|
+
{ mode: "prod" },
|
|
195
|
+
"offerings_checkout_sessions_mode_domain",
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
"a live record of a test session",
|
|
199
|
+
{ mode: "live" },
|
|
200
|
+
"offerings_checkout_sessions_session_mode_agree",
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
"a test record of a live session",
|
|
204
|
+
{ session_id: "cs_live_a1" },
|
|
205
|
+
"offerings_checkout_sessions_session_mode_agree",
|
|
206
|
+
],
|
|
207
|
+
[
|
|
208
|
+
"an id of another shape",
|
|
209
|
+
{ id: "ocsess_1" },
|
|
210
|
+
"offerings_checkout_sessions_id_format",
|
|
211
|
+
],
|
|
212
|
+
])("refuses %s", async (_label, cols, constraint) => {
|
|
213
|
+
expect(await violation(() => insert(cols))).toBe(constraint);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("refuses a second record of one session", async () => {
|
|
217
|
+
expect(
|
|
218
|
+
await violation(async () => {
|
|
219
|
+
await insert();
|
|
220
|
+
await insert({ price_id: "price_2Def" });
|
|
221
|
+
}),
|
|
222
|
+
).toBe("offerings_checkout_sessions_session_id_key");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("AC-3 — the lookup-key CHECK accepts exactly what parseLookupKey accepts", () => {
|
|
227
|
+
const CORPUS = [
|
|
228
|
+
// Contract §3.7's worked examples.
|
|
229
|
+
"growth_monthly",
|
|
230
|
+
"growth_yearly",
|
|
231
|
+
"basic_once",
|
|
232
|
+
"samhenry-coaching_monthly",
|
|
233
|
+
"growth_quarterly",
|
|
234
|
+
"growth_pro_monthly",
|
|
235
|
+
"Growth_Monthly",
|
|
236
|
+
"wendi_retainer",
|
|
237
|
+
// The anchors, the plan's first character, and its length bound.
|
|
238
|
+
"-growth_monthly",
|
|
239
|
+
"growth-_once",
|
|
240
|
+
"growth_monthly ",
|
|
241
|
+
"growth_monthly\n",
|
|
242
|
+
"_monthly",
|
|
243
|
+
`${"a".repeat(40)}_yearly`,
|
|
244
|
+
`${"a".repeat(41)}_monthly`,
|
|
245
|
+
// 46 characters: inside VARCHAR(48), so only the CHECK can refuse it.
|
|
246
|
+
`${"a".repeat(41)}_once`,
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
it("the corpus holds keys each side accepts and keys each side refuses", () => {
|
|
250
|
+
const accepted = CORPUS.filter((key) => parseLookupKey(key) !== null);
|
|
251
|
+
expect(accepted).toEqual([
|
|
252
|
+
"growth_monthly",
|
|
253
|
+
"growth_yearly",
|
|
254
|
+
"basic_once",
|
|
255
|
+
"samhenry-coaching_monthly",
|
|
256
|
+
"growth-_once",
|
|
257
|
+
`${"a".repeat(40)}_yearly`,
|
|
258
|
+
]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it.each(CORPUS.map((key) => [JSON.stringify(key), key]))(
|
|
262
|
+
"inserts %s if and only if the parser accepts it",
|
|
263
|
+
async (_label, key) => {
|
|
264
|
+
const refused = await violation(() => insert({ lookup_key: key }));
|
|
265
|
+
expect(refused === null).toBe(parseLookupKey(key) !== null);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file offerings-migration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* DB-free proof of the offerings_data_model migration's shape (#8876, spec
|
|
5
|
+
* docs/specs/8876-the-offerings-data-model-typed-spec.md D1, D2, AC-1).
|
|
6
|
+
* Reads the migration's own text and the config schema: it creates exactly
|
|
7
|
+
* the three tables, with every column the models `@map` to, the three
|
|
8
|
+
* `_id_format` CHECKs, the three reference-column format CHECKs and every
|
|
9
|
+
* integrity CHECK D1 names — and touches no other table. The live-database
|
|
10
|
+
* half is `offerings.db.test.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const MIGRATION = join(
|
|
16
|
+
__dirname,
|
|
17
|
+
"..",
|
|
18
|
+
"migrations",
|
|
19
|
+
"20261020120000_offerings_data_model",
|
|
20
|
+
"migration.sql",
|
|
21
|
+
);
|
|
22
|
+
const SCHEMA = join(__dirname, "..", "config", "schema.prisma");
|
|
23
|
+
|
|
24
|
+
const TABLES: Record<string, { model: string; columns: string[] }> = {
|
|
25
|
+
stripe_connections: {
|
|
26
|
+
model: "StripeConnection",
|
|
27
|
+
columns: [
|
|
28
|
+
"id",
|
|
29
|
+
"organization_id",
|
|
30
|
+
"mode",
|
|
31
|
+
"key_last4",
|
|
32
|
+
"credential_ciphertext",
|
|
33
|
+
"webhook_token",
|
|
34
|
+
"webhook_secret_ciphertext",
|
|
35
|
+
"live_at",
|
|
36
|
+
"created_at",
|
|
37
|
+
"updated_at",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
organization_offerings: {
|
|
41
|
+
model: "OrganizationOfferings",
|
|
42
|
+
columns: ["id", "organization_id", "enabled_product_ids", "created_at", "updated_at"],
|
|
43
|
+
},
|
|
44
|
+
workspace_offerings: {
|
|
45
|
+
model: "WorkspaceOfferings",
|
|
46
|
+
columns: [
|
|
47
|
+
"id",
|
|
48
|
+
"workspace_id",
|
|
49
|
+
"shown_product_ids",
|
|
50
|
+
"recommended_plan_product_id",
|
|
51
|
+
"recommended_addon_product_id",
|
|
52
|
+
"snapshot",
|
|
53
|
+
"snapshot_at",
|
|
54
|
+
"created_at",
|
|
55
|
+
"updated_at",
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Columns a later migration adds, after each table's own: #9070's
|
|
62
|
+
* `shown_price_ids` (offerings-shown-price-ids-migration.test.ts).
|
|
63
|
+
*/
|
|
64
|
+
const LATER_COLUMNS: Record<string, string[]> = {
|
|
65
|
+
workspace_offerings: ["shown_price_ids"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const CHECKS = [
|
|
69
|
+
"stripe_connections_id_format",
|
|
70
|
+
"stripe_connections_organization_id_format",
|
|
71
|
+
"organization_offerings_id_format",
|
|
72
|
+
"organization_offerings_organization_id_format",
|
|
73
|
+
"workspace_offerings_id_format",
|
|
74
|
+
"workspace_offerings_workspace_id_format",
|
|
75
|
+
"stripe_connections_mode_domain",
|
|
76
|
+
"stripe_connections_live_at_pair",
|
|
77
|
+
"stripe_connections_key_last4_shape",
|
|
78
|
+
"stripe_connections_webhook_token_shape",
|
|
79
|
+
"stripe_connections_credential_envelope",
|
|
80
|
+
"stripe_connections_webhook_secret_envelope",
|
|
81
|
+
"organization_offerings_enabled_product_ids_bound",
|
|
82
|
+
"workspace_offerings_shown_product_ids_bound",
|
|
83
|
+
"workspace_offerings_recommended_plan_shown",
|
|
84
|
+
"workspace_offerings_recommended_addon_shown",
|
|
85
|
+
"workspace_offerings_snapshot_pair",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/** The migration's statements, comments stripped, whitespace collapsed. */
|
|
89
|
+
function statements(): string[] {
|
|
90
|
+
const text = readFileSync(MIGRATION, "utf8")
|
|
91
|
+
.split("\n")
|
|
92
|
+
.filter((line) => !line.trimStart().startsWith("--"))
|
|
93
|
+
.join("\n");
|
|
94
|
+
return text
|
|
95
|
+
.split(";")
|
|
96
|
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
97
|
+
.filter((s) => s.length > 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One model's body from the config schema. */
|
|
101
|
+
function modelBody(model: string): string {
|
|
102
|
+
const match = new RegExp(`^model ${model} \\{\\n([\\s\\S]*?)^\\}`, "m").exec(
|
|
103
|
+
readFileSync(SCHEMA, "utf8"),
|
|
104
|
+
);
|
|
105
|
+
if (!match) throw new Error(`model ${model} not found`);
|
|
106
|
+
return match[1]!;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The column each field of a model maps to: its `@map`, else its own name. */
|
|
110
|
+
function mappedColumns(model: string): string[] {
|
|
111
|
+
return modelBody(model)
|
|
112
|
+
.split("\n")
|
|
113
|
+
.map((line) => line.trim())
|
|
114
|
+
.filter((line) => /^[a-zA-Z]\w*\s/.test(line))
|
|
115
|
+
.map((line) => /@map\("([^"]+)"\)/.exec(line)?.[1] ?? line.split(/\s+/)[0]!);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("the offerings_data_model migration (#8876 D2, AC-1)", () => {
|
|
119
|
+
it("creates exactly the three tables, each column the model maps to", () => {
|
|
120
|
+
const creates = statements().filter((s) => s.startsWith("CREATE TABLE "));
|
|
121
|
+
expect(creates.map((s) => /^CREATE TABLE "([a-z_]+)"/.exec(s)?.[1])).toEqual(
|
|
122
|
+
Object.keys(TABLES),
|
|
123
|
+
);
|
|
124
|
+
for (const [table, { model, columns }] of Object.entries(TABLES)) {
|
|
125
|
+
const create = creates.find((s) => s.startsWith(`CREATE TABLE "${table}"`))!;
|
|
126
|
+
const created = [...create.matchAll(/(?:\( |, )"([a-z0-9_]+)" [A-Z]/g)].map((m) => m[1]);
|
|
127
|
+
expect(created).toEqual(columns);
|
|
128
|
+
// #9070 D1 — a later migration adds one column to workspace_offerings.
|
|
129
|
+
const later = LATER_COLUMNS[table] ?? [];
|
|
130
|
+
expect(mappedColumns(model).filter((c) => !later.includes(c))).toEqual(columns);
|
|
131
|
+
expect(mappedColumns(model)).toEqual(expect.arrayContaining(later));
|
|
132
|
+
expect(modelBody(model)).toContain(`@@map("${table}")`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("holds one unique index per key and on the webhook token", () => {
|
|
137
|
+
const indexes = statements()
|
|
138
|
+
.map((s) => /^CREATE UNIQUE INDEX "([a-z0-9_]+)"/.exec(s)?.[1])
|
|
139
|
+
.filter((name): name is string => name !== undefined);
|
|
140
|
+
expect(indexes).toEqual([
|
|
141
|
+
"stripe_connections_organization_id_key",
|
|
142
|
+
"stripe_connections_webhook_token_key",
|
|
143
|
+
"organization_offerings_organization_id_key",
|
|
144
|
+
"workspace_offerings_workspace_id_key",
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("adds every named CHECK, each once", () => {
|
|
149
|
+
const added = statements()
|
|
150
|
+
.map((s) => /^ALTER TABLE "[a-z_]+" ADD CONSTRAINT "([a-z0-9_]+)" CHECK /.exec(s)?.[1])
|
|
151
|
+
.filter((name): name is string => name !== undefined);
|
|
152
|
+
expect(added).toEqual(CHECKS);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("pins each CHECK's rule", () => {
|
|
156
|
+
const sql = statements().join("\n");
|
|
157
|
+
const rule = (name: string) =>
|
|
158
|
+
new RegExp(`ADD CONSTRAINT "${name}" CHECK (.*)`).exec(sql)?.[1];
|
|
159
|
+
expect(rule("stripe_connections_mode_domain")).toBe(`("mode" IN ('test', 'live'))`);
|
|
160
|
+
expect(rule("stripe_connections_live_at_pair")).toBe(
|
|
161
|
+
`(("mode" = 'live') = ("live_at" IS NOT NULL))`,
|
|
162
|
+
);
|
|
163
|
+
expect(rule("stripe_connections_webhook_token_shape")).toBe(
|
|
164
|
+
`("webhook_token" ~ '^[A-Za-z0-9_-]{43}$')`,
|
|
165
|
+
);
|
|
166
|
+
const envelope = `'^v1\\.[A-Za-z0-9_-]{16}\\.[A-Za-z0-9_-]{22}\\.[A-Za-z0-9_-]+$'`;
|
|
167
|
+
const keyPrefix = `'(rk|sk|pk)_(test|live)_|whsec_'`;
|
|
168
|
+
expect(rule("stripe_connections_credential_envelope")).toBe(
|
|
169
|
+
`("credential_ciphertext" ~ ${envelope} AND "credential_ciphertext" !~ ${keyPrefix})`,
|
|
170
|
+
);
|
|
171
|
+
expect(rule("stripe_connections_webhook_secret_envelope")).toBe(
|
|
172
|
+
`("webhook_secret_ciphertext" IS NULL OR ("webhook_secret_ciphertext" ~ ${envelope} AND "webhook_secret_ciphertext" !~ ${keyPrefix}))`,
|
|
173
|
+
);
|
|
174
|
+
for (const [name, column] of [
|
|
175
|
+
["organization_offerings_enabled_product_ids_bound", "enabled_product_ids"],
|
|
176
|
+
["workspace_offerings_shown_product_ids_bound", "shown_product_ids"],
|
|
177
|
+
]) {
|
|
178
|
+
expect(rule(name!)).toBe(
|
|
179
|
+
`("${column}" IS NOT NULL AND cardinality("${column}") <= 100 AND array_position("${column}", NULL) IS NULL)`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
expect(rule("workspace_offerings_snapshot_pair")).toBe(
|
|
183
|
+
`(("snapshot" IS NULL) = ("snapshot_at" IS NULL))`,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("touches no other table: no UPDATE, INSERT, DELETE, DROP, or ALTER of another table", () => {
|
|
188
|
+
const own = new Set(Object.keys(TABLES));
|
|
189
|
+
for (const s of statements()) {
|
|
190
|
+
expect(s).not.toMatch(/^(UPDATE|INSERT|DELETE|DROP|TRUNCATE)\b/);
|
|
191
|
+
const altered = /^ALTER TABLE "([a-z_]+)"/.exec(s)?.[1];
|
|
192
|
+
if (altered) expect(own.has(altered)).toBe(true);
|
|
193
|
+
const created = /^CREATE (?:UNIQUE )?INDEX "[a-z0-9_]+" ON "([a-z_]+)"/.exec(s)?.[1];
|
|
194
|
+
if (created) expect(own.has(created)).toBe(true);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file offerings-shown-price-ids-migration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* DB-free proof of the offerings_shown_price_ids migration's shape (#9070,
|
|
5
|
+
* spec docs/specs/9070-the-workspace-offerings-page-becomes-spec.md D1,
|
|
6
|
+
* AC-6). Reads the migration's own text and the config schema: it adds
|
|
7
|
+
* exactly one column to workspace_offerings, NOT NULL with the empty-array
|
|
8
|
+
* default, and exactly two CHECKs, each added NOT VALID then VALIDATEd — and
|
|
9
|
+
* creates, drops and updates nothing else, so no existing row is rewritten.
|
|
10
|
+
* The live-database half is `offerings.db.test.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const MIGRATION = join(
|
|
16
|
+
__dirname,
|
|
17
|
+
"..",
|
|
18
|
+
"migrations",
|
|
19
|
+
"20261023120000_offerings_shown_price_ids",
|
|
20
|
+
"migration.sql",
|
|
21
|
+
);
|
|
22
|
+
const SCHEMA = join(__dirname, "..", "config", "schema.prisma");
|
|
23
|
+
|
|
24
|
+
const CHECKS = [
|
|
25
|
+
"workspace_offerings_shown_price_ids_bound",
|
|
26
|
+
"workspace_offerings_shown_prices_need_products",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** The migration's statements, comments stripped, whitespace collapsed. */
|
|
30
|
+
function statements(): string[] {
|
|
31
|
+
const text = readFileSync(MIGRATION, "utf8")
|
|
32
|
+
.split("\n")
|
|
33
|
+
.filter((line) => !line.trimStart().startsWith("--"))
|
|
34
|
+
.join("\n");
|
|
35
|
+
return text
|
|
36
|
+
.split(";")
|
|
37
|
+
.map((s) => s.replace(/\s+/g, " ").trim())
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("the offerings_shown_price_ids migration (#9070 D1, AC-6)", () => {
|
|
42
|
+
it("adds exactly one column, NOT NULL with the empty-array default", () => {
|
|
43
|
+
const adds = statements().filter((s) => / ADD COLUMN /.test(s));
|
|
44
|
+
expect(adds).toEqual([
|
|
45
|
+
`ALTER TABLE "workspace_offerings" ADD COLUMN "shown_price_ids" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]`,
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("adds exactly the two CHECKs, each NOT VALID then VALIDATEd", () => {
|
|
50
|
+
const added = statements()
|
|
51
|
+
.map((s) => /^ALTER TABLE "workspace_offerings" ADD CONSTRAINT "([a-z0-9_]+)" CHECK .* NOT VALID$/.exec(s)?.[1])
|
|
52
|
+
.filter((name): name is string => name !== undefined);
|
|
53
|
+
expect(added).toEqual(CHECKS);
|
|
54
|
+
const validated = statements()
|
|
55
|
+
.map((s) => /^ALTER TABLE "workspace_offerings" VALIDATE CONSTRAINT "([a-z0-9_]+)"$/.exec(s)?.[1])
|
|
56
|
+
.filter((name): name is string => name !== undefined);
|
|
57
|
+
expect(validated).toEqual(CHECKS);
|
|
58
|
+
expect(statements()).toHaveLength(1 + 2 * CHECKS.length);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("pins each CHECK's rule", () => {
|
|
62
|
+
const sql = statements().join("\n");
|
|
63
|
+
const rule = (name: string) =>
|
|
64
|
+
new RegExp(`ADD CONSTRAINT "${name}" CHECK (.*) NOT VALID`).exec(sql)?.[1];
|
|
65
|
+
expect(rule("workspace_offerings_shown_price_ids_bound")).toBe(
|
|
66
|
+
`(cardinality("shown_price_ids") <= 300 AND array_position("shown_price_ids", NULL) IS NULL)`,
|
|
67
|
+
);
|
|
68
|
+
expect(rule("workspace_offerings_shown_prices_need_products")).toBe(
|
|
69
|
+
`(cardinality("shown_price_ids") = 0 OR cardinality("shown_product_ids") > 0)`,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("creates, drops and updates nothing, and alters no other table", () => {
|
|
74
|
+
for (const s of statements()) {
|
|
75
|
+
expect(s).not.toMatch(/^(UPDATE|INSERT|DELETE|DROP|TRUNCATE|CREATE)\b/);
|
|
76
|
+
expect(s).toMatch(/^ALTER TABLE "workspace_offerings" /);
|
|
77
|
+
expect(s).not.toMatch(/ DROP /);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("the model maps the column with the empty-list default", () => {
|
|
82
|
+
expect(readFileSync(SCHEMA, "utf8")).toMatch(
|
|
83
|
+
/^ {2}shownPriceIds +String\[\] +@default\(\[\]\) @map\("shown_price_ids"\)$/m,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|