create-ailk 0.8.0 → 0.10.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/README.md +71 -3
- package/component-catalog.json +279 -45
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +104 -5
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +48 -32
- package/dist/lib/module-license-gate.js +117 -8
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +67 -23
- package/dist/parse-args.d.ts +12 -2
- package/dist/parse-args.js +74 -13
- package/dist/programmatic.d.ts +30 -6
- package/dist/programmatic.js +216 -19
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +141 -8
- package/dist/starter-grid.d.ts +16 -63
- package/dist/starter-grid.js +102 -84
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +200 -83
- package/dist/template-manifest.d.ts +30 -13
- package/dist/template-manifest.js +53 -28
- package/package.json +6 -3
- package/release-scope.json +25 -0
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +1 -1
- package/site-templates/designs/standard/DESIGN.md +98 -6
- package/site-templates/marketing-site/DESIGN.md +98 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
- package/site-templates/marketing-site-free/DESIGN.md +98 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/saas-product/DESIGN.md +98 -6
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
- package/site-templates/shapes/person-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
- package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
- package/templates/.claude/agents/web.md +10 -8
- package/templates/.claude/rules/architecture.md +17 -7
- package/templates/.claude/skills/README.md +3 -2
- package/templates/.claude/skills/add-auth/SKILL.md +142 -90
- package/templates/.claude/skills/add-auth/references/env-vars.md +32 -17
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +17 -8
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +94 -86
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +51 -18
- package/templates/.claude/skills/check-site/references/common-errors.md +12 -9
- package/templates/.claude/skills/check-site/references/findings-glossary.md +4 -4
- package/templates/.claude/skills/check-site/references/severity-levels.md +3 -3
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-deploy/SKILL.md +75 -38
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.env.example +23 -2
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +98 -6
- package/templates/README.md +50 -38
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +35 -19
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +14 -13
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +23 -9
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +40 -1
- package/templates/content/en/HomePage/home.mdx +2 -6
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +4 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/dist/modules/commerce/0.6.0.tar.gz +0 -0
- package/dist/modules/commerce/0.6.1.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.1.tar.gz +0 -0
- package/dist/modules/marketing-site/0.6.1.tar.gz +0 -0
- package/dist/modules/minimalism-site/0.6.1.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.1.tar.gz +0 -0
- package/dist/modules/saas-product/0.6.1.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.1.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.1.tar.gz +0 -0
- package/dist/modules/simple-service/0.6.1.tar.gz +0 -0
- package/dist/modules/workspace/0.6.0.tar.gz +0 -0
- package/dist/modules/workspace/0.6.1.tar.gz +0 -0
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +0 -506
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +0 -175
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +0 -8
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +0 -12
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +0 -12
- package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
|
@@ -128,6 +128,13 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
128
128
|
afterEach(async () => {
|
|
129
129
|
await server.close();
|
|
130
130
|
jest.restoreAllMocks();
|
|
131
|
+
// clearAllMocks keeps queued values: a case that asserts a read must NOT
|
|
132
|
+
// happen (#6852) would otherwise leak its unconsumed value into the next.
|
|
133
|
+
(prisma.user.findFirst as jest.Mock).mockReset();
|
|
134
|
+
(prisma.user.findUnique as jest.Mock).mockReset();
|
|
135
|
+
(prisma.user.update as jest.Mock).mockReset();
|
|
136
|
+
(prisma.organization.findFirst as jest.Mock).mockReset();
|
|
137
|
+
(prisma.organization.update as jest.Mock).mockReset();
|
|
131
138
|
});
|
|
132
139
|
|
|
133
140
|
// ── Case 1: Missing config returns 503 ──────────────────────────────────
|
|
@@ -483,6 +490,66 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
483
490
|
expect(response.statusCode).toBe(404);
|
|
484
491
|
});
|
|
485
492
|
|
|
493
|
+
// ── Log capture (shared by the #6113 and #6852 blocks) ─────────────────────
|
|
494
|
+
|
|
495
|
+
type LogLine = {
|
|
496
|
+
level: number;
|
|
497
|
+
msg?: string;
|
|
498
|
+
outcome?: string;
|
|
499
|
+
userId?: string;
|
|
500
|
+
eventType?: string;
|
|
501
|
+
};
|
|
502
|
+
const WARN = 40;
|
|
503
|
+
const INFO = 30;
|
|
504
|
+
const ERROR = 50;
|
|
505
|
+
|
|
506
|
+
// A log-capture instance built the way the "does not log customer email…"
|
|
507
|
+
// case builds one — the default buildTestServer() captures no logs.
|
|
508
|
+
async function withLogCapture(
|
|
509
|
+
event: Record<string, unknown>,
|
|
510
|
+
): Promise<{ statusCode: number; logLines: LogLine[] }> {
|
|
511
|
+
const logLines: LogLine[] = [];
|
|
512
|
+
const logCapture = Fastify({
|
|
513
|
+
logger: {
|
|
514
|
+
level: "info",
|
|
515
|
+
stream: {
|
|
516
|
+
write(msg: string) {
|
|
517
|
+
logLines.push(JSON.parse(msg) as LogLine);
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
await logCapture.register(async (scope) => {
|
|
523
|
+
scope.addContentTypeParser(
|
|
524
|
+
"application/json",
|
|
525
|
+
{ parseAs: "buffer" },
|
|
526
|
+
(_req, body, done) => {
|
|
527
|
+
done(null, body);
|
|
528
|
+
},
|
|
529
|
+
);
|
|
530
|
+
scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
531
|
+
});
|
|
532
|
+
await logCapture.ready();
|
|
533
|
+
try {
|
|
534
|
+
mockVerifyWebhookSignature.mockReturnValue(event);
|
|
535
|
+
const response = await logCapture.inject({
|
|
536
|
+
method: "POST",
|
|
537
|
+
url: "/v1/webhooks/stripe",
|
|
538
|
+
payload: VALID_BODY,
|
|
539
|
+
headers: {
|
|
540
|
+
"content-type": "application/json",
|
|
541
|
+
"stripe-signature": VALID_SIGNATURE,
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
return { statusCode: response.statusCode, logLines };
|
|
545
|
+
} finally {
|
|
546
|
+
await logCapture.close();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const withOutcome = (lines: LogLine[], outcome: string) =>
|
|
551
|
+
lines.filter((l) => l.outcome === outcome);
|
|
552
|
+
|
|
486
553
|
// ── Billing upsert tests (AC bullets 4-6) ──────────────────────────────────
|
|
487
554
|
|
|
488
555
|
// AC bullet 4: customer.created → upsert; re-fire same event = no second write
|
|
@@ -495,6 +562,7 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
495
562
|
object: {
|
|
496
563
|
id: customerId,
|
|
497
564
|
email: "alice@example.com",
|
|
565
|
+
metadata: { ailk_user_id: "usr_1" },
|
|
498
566
|
},
|
|
499
567
|
},
|
|
500
568
|
};
|
|
@@ -507,7 +575,7 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
507
575
|
|
|
508
576
|
// First fire: typed composite match misses (user not yet linked)
|
|
509
577
|
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
510
|
-
//
|
|
578
|
+
// the verified user signal resolves the user by id
|
|
511
579
|
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
512
580
|
id: "usr_1",
|
|
513
581
|
email: "alice@example.com",
|
|
@@ -553,8 +621,9 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
553
621
|
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
554
622
|
});
|
|
555
623
|
|
|
556
|
-
// AC bullet 5
|
|
557
|
-
|
|
624
|
+
// AC bullet 5 (inverted by #6852): the Checkout email is payer-typed and
|
|
625
|
+
// unverified, so an email match alone never makes a first binding.
|
|
626
|
+
it("does not bind a never-bound User matched only by email (#6852)", async () => {
|
|
558
627
|
const customerId = "cus_test_checkout_xyz";
|
|
559
628
|
const fakeCheckout = {
|
|
560
629
|
id: "evt_checkout_002",
|
|
@@ -570,23 +639,16 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
570
639
|
},
|
|
571
640
|
},
|
|
572
641
|
};
|
|
573
|
-
const updatedUser = {
|
|
574
|
-
id: "usr_2",
|
|
575
|
-
email: "bob@example.com",
|
|
576
|
-
billingProvider: "stripe",
|
|
577
|
-
billingCustomerId: customerId,
|
|
578
|
-
};
|
|
579
642
|
|
|
580
643
|
// Typed composite lookup misses (no prior link)
|
|
581
644
|
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
582
|
-
//
|
|
645
|
+
// Would find the user by email — if the handler still asked
|
|
583
646
|
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
584
647
|
id: "usr_2",
|
|
585
648
|
email: "bob@example.com",
|
|
586
649
|
billingProvider: null,
|
|
587
650
|
billingCustomerId: null,
|
|
588
651
|
});
|
|
589
|
-
(prisma.user.update as jest.Mock).mockResolvedValueOnce(updatedUser);
|
|
590
652
|
|
|
591
653
|
mockVerifyWebhookSignature.mockReturnValue(fakeCheckout);
|
|
592
654
|
const response = await server.inject({
|
|
@@ -600,15 +662,8 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
600
662
|
});
|
|
601
663
|
|
|
602
664
|
expect(response.statusCode).toBe(200);
|
|
603
|
-
expect(prisma.user.
|
|
604
|
-
|
|
605
|
-
where: { id: "usr_2" },
|
|
606
|
-
data: expect.objectContaining({
|
|
607
|
-
billingProvider: "stripe",
|
|
608
|
-
billingCustomerId: customerId,
|
|
609
|
-
}),
|
|
610
|
-
}),
|
|
611
|
-
);
|
|
665
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
666
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
612
667
|
});
|
|
613
668
|
|
|
614
669
|
// AC bullet 6: event with neither typed match nor email match → 200 + warning, no User row created
|
|
@@ -645,4 +700,450 @@ describe("POST /api/webhooks/stripe", () => {
|
|
|
645
700
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
646
701
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
|
647
702
|
});
|
|
703
|
+
|
|
704
|
+
// ── #6113: a bound customer is never re-pointed (now on the signal, #6852) ──
|
|
705
|
+
|
|
706
|
+
describe("signal against an already-bound User (#6113)", () => {
|
|
707
|
+
function checkoutCompleted(customer: string, userId: string) {
|
|
708
|
+
return {
|
|
709
|
+
id: "evt_6113_checkout",
|
|
710
|
+
type: "checkout.session.completed",
|
|
711
|
+
data: {
|
|
712
|
+
object: {
|
|
713
|
+
id: "cs_test_6113",
|
|
714
|
+
amount_total: 4999,
|
|
715
|
+
currency: "usd",
|
|
716
|
+
customer,
|
|
717
|
+
customer_details: { email: "victim@example.com" },
|
|
718
|
+
metadata: { productId: "pro", ailk_user_id: userId },
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function customerCreated(id: string, userId: string) {
|
|
725
|
+
return {
|
|
726
|
+
id: "evt_6113_customer",
|
|
727
|
+
type: "customer.created",
|
|
728
|
+
data: {
|
|
729
|
+
object: {
|
|
730
|
+
id,
|
|
731
|
+
email: "victim@example.com",
|
|
732
|
+
metadata: { ailk_user_id: userId },
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const expectFirstReadById = (id: string) =>
|
|
739
|
+
expect((prisma.user.findUnique as jest.Mock).mock.calls[0]?.[0]).toEqual(
|
|
740
|
+
expect.objectContaining({ where: { id } }),
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
const victimRow = {
|
|
744
|
+
id: "usr_victim",
|
|
745
|
+
email: "victim@example.com",
|
|
746
|
+
billingProvider: "stripe",
|
|
747
|
+
billingCustomerId: "cus_victim",
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// T6a
|
|
751
|
+
it("does not re-point a bound billingCustomerId from a Checkout naming the victim", async () => {
|
|
752
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
753
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(victimRow);
|
|
754
|
+
|
|
755
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
756
|
+
checkoutCompleted("cus_attacker", "usr_victim"),
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
expect(statusCode).toBe(200);
|
|
760
|
+
expectFirstReadById("usr_victim");
|
|
761
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
762
|
+
const conflicts = withOutcome(logLines, "billing_identity_conflict");
|
|
763
|
+
expect(conflicts).toHaveLength(1);
|
|
764
|
+
expect(conflicts[0]).toMatchObject({ level: WARN, userId: "usr_victim" });
|
|
765
|
+
const logText = JSON.stringify(logLines);
|
|
766
|
+
expect(logText).not.toContain("victim@example.com");
|
|
767
|
+
expect(logText).not.toContain("cus_attacker");
|
|
768
|
+
expect(logText).not.toContain("cus_victim");
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// T6b (the customer.created branch)
|
|
772
|
+
it("does not re-point a bound billingCustomerId from customer.created naming the victim", async () => {
|
|
773
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
774
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(victimRow);
|
|
775
|
+
|
|
776
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
777
|
+
customerCreated("cus_attacker", "usr_victim"),
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
expect(statusCode).toBe(200);
|
|
781
|
+
expectFirstReadById("usr_victim");
|
|
782
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
783
|
+
const conflicts = withOutcome(logLines, "billing_identity_conflict");
|
|
784
|
+
expect(conflicts).toHaveLength(1);
|
|
785
|
+
expect(conflicts[0]).toMatchObject({ level: WARN, userId: "usr_victim" });
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// T6c (a lost race to a different customer is a refusal, not a DB failure)
|
|
789
|
+
it("guards the write and treats a P2025 lost to another customer as a conflict", async () => {
|
|
790
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
791
|
+
(prisma.user.findUnique as jest.Mock)
|
|
792
|
+
.mockResolvedValueOnce({
|
|
793
|
+
id: "usr_race",
|
|
794
|
+
billingProvider: null,
|
|
795
|
+
billingCustomerId: null,
|
|
796
|
+
})
|
|
797
|
+
.mockResolvedValueOnce({
|
|
798
|
+
billingProvider: "stripe",
|
|
799
|
+
billingCustomerId: "cus_other",
|
|
800
|
+
});
|
|
801
|
+
(prisma.user.update as jest.Mock).mockRejectedValueOnce(
|
|
802
|
+
Object.assign(new Error("not found"), { code: "P2025" }),
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
806
|
+
checkoutCompleted("cus_new", "usr_race"),
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
expect(statusCode).toBe(200);
|
|
810
|
+
expectFirstReadById("usr_race");
|
|
811
|
+
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
812
|
+
expect.objectContaining({
|
|
813
|
+
where: { id: "usr_race", billingCustomerId: null },
|
|
814
|
+
}),
|
|
815
|
+
);
|
|
816
|
+
const conflicts = withOutcome(logLines, "billing_identity_conflict");
|
|
817
|
+
expect(conflicts).toHaveLength(1);
|
|
818
|
+
expect(conflicts[0]).toMatchObject({ level: WARN, userId: "usr_race" });
|
|
819
|
+
expect(
|
|
820
|
+
logLines.filter(
|
|
821
|
+
(l) =>
|
|
822
|
+
l.level === ERROR &&
|
|
823
|
+
l.msg?.includes("billing identity upsert failed"),
|
|
824
|
+
),
|
|
825
|
+
).toHaveLength(0);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// T6d (the same id under another provider is still "already bound")
|
|
829
|
+
it("does not rewrite a row holding the same id under a different provider", async () => {
|
|
830
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
831
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
832
|
+
id: "usr_same",
|
|
833
|
+
billingProvider: null,
|
|
834
|
+
billingCustomerId: "cus_same",
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
838
|
+
customerCreated("cus_same", "usr_same"),
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
expect(statusCode).toBe(200);
|
|
842
|
+
expectFirstReadById("usr_same");
|
|
843
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
844
|
+
expect(withOutcome(logLines, "billing_identity_conflict")).toHaveLength(
|
|
845
|
+
1,
|
|
846
|
+
);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// T6e (a race lost to a sibling event for the SAME customer is a no-op)
|
|
850
|
+
it("treats a P2025 lost to a sibling event for the same customer as already linked", async () => {
|
|
851
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
852
|
+
(prisma.user.findUnique as jest.Mock)
|
|
853
|
+
.mockResolvedValueOnce({
|
|
854
|
+
id: "usr_sib",
|
|
855
|
+
billingProvider: null,
|
|
856
|
+
billingCustomerId: null,
|
|
857
|
+
})
|
|
858
|
+
.mockResolvedValueOnce({
|
|
859
|
+
billingProvider: "stripe",
|
|
860
|
+
billingCustomerId: "cus_new",
|
|
861
|
+
});
|
|
862
|
+
(prisma.user.update as jest.Mock).mockRejectedValueOnce(
|
|
863
|
+
Object.assign(new Error("not found"), { code: "P2025" }),
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
867
|
+
checkoutCompleted("cus_new", "usr_sib"),
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
expect(statusCode).toBe(200);
|
|
871
|
+
expectFirstReadById("usr_sib");
|
|
872
|
+
expect(withOutcome(logLines, "billing_identity_conflict")).toHaveLength(
|
|
873
|
+
0,
|
|
874
|
+
);
|
|
875
|
+
const linked = withOutcome(logLines, "billing_identity_already_linked");
|
|
876
|
+
expect(linked).toHaveLength(1);
|
|
877
|
+
expect(linked[0]).toMatchObject({ level: INFO });
|
|
878
|
+
expect(logLines.filter((l) => l.level === ERROR)).toHaveLength(0);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// T6f (the pre-check sees the sibling's binding already committed)
|
|
882
|
+
it("treats a signal-matched row already bound to this customer as a no-op", async () => {
|
|
883
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
884
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
885
|
+
id: "usr_sib",
|
|
886
|
+
billingProvider: "stripe",
|
|
887
|
+
billingCustomerId: "cus_new",
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
891
|
+
checkoutCompleted("cus_new", "usr_sib"),
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
expect(statusCode).toBe(200);
|
|
895
|
+
expectFirstReadById("usr_sib");
|
|
896
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
897
|
+
expect(withOutcome(logLines, "billing_identity_conflict")).toHaveLength(
|
|
898
|
+
0,
|
|
899
|
+
);
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// ── #6852: a first binding needs a server-stamped user id, never an email ──
|
|
904
|
+
|
|
905
|
+
describe("verified user signal (#6852)", () => {
|
|
906
|
+
function checkoutCompleted(
|
|
907
|
+
customer: string,
|
|
908
|
+
metadata: Record<string, unknown>,
|
|
909
|
+
extra: Record<string, unknown> = {},
|
|
910
|
+
) {
|
|
911
|
+
return {
|
|
912
|
+
id: "evt_6852_checkout",
|
|
913
|
+
type: "checkout.session.completed",
|
|
914
|
+
data: {
|
|
915
|
+
object: {
|
|
916
|
+
id: "cs_test_6852",
|
|
917
|
+
amount_total: 4999,
|
|
918
|
+
currency: "usd",
|
|
919
|
+
customer,
|
|
920
|
+
customer_details: { email: "victim@example.com" },
|
|
921
|
+
metadata,
|
|
922
|
+
...extra,
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function customerCreated(
|
|
929
|
+
id: string,
|
|
930
|
+
email: string | null,
|
|
931
|
+
metadata?: Record<string, string>,
|
|
932
|
+
) {
|
|
933
|
+
return {
|
|
934
|
+
id: "evt_6852_customer",
|
|
935
|
+
type: "customer.created",
|
|
936
|
+
data: { object: { id, email, ...(metadata ? { metadata } : {}) } },
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const neverBound = (id: string) => ({
|
|
941
|
+
id,
|
|
942
|
+
billingProvider: null,
|
|
943
|
+
billingCustomerId: null,
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
const findUniqueWheres = () =>
|
|
947
|
+
(prisma.user.findUnique as jest.Mock).mock.calls.map(
|
|
948
|
+
(c) => (c[0] as { where: Record<string, unknown> }).where,
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
beforeEach(() => {
|
|
952
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// T1 (AC1 — checkout.session.completed)
|
|
956
|
+
it("T1: a Checkout under the victim's email with no signal binds nothing", async () => {
|
|
957
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(
|
|
958
|
+
neverBound("usr_victim"),
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
962
|
+
checkoutCompleted("cus_attacker", { productId: "pro" }),
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
expect(statusCode).toBe(200);
|
|
966
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
967
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
968
|
+
const unverified = withOutcome(logLines, "billing_identity_unverified");
|
|
969
|
+
expect(unverified).toHaveLength(1);
|
|
970
|
+
expect(unverified[0]).toMatchObject({
|
|
971
|
+
level: WARN,
|
|
972
|
+
eventType: "checkout.session.completed",
|
|
973
|
+
});
|
|
974
|
+
const logText = JSON.stringify(logLines);
|
|
975
|
+
expect(logText).not.toContain("victim@example.com");
|
|
976
|
+
expect(logText).not.toContain("cus_attacker");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// T2 (AC1 — customer.created)
|
|
980
|
+
it("T2: customer.created under the victim's email with no signal binds nothing", async () => {
|
|
981
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(
|
|
982
|
+
neverBound("usr_victim"),
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
986
|
+
customerCreated("cus_attacker", "victim@example.com"),
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
expect(statusCode).toBe(200);
|
|
990
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
991
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
992
|
+
expect(withOutcome(logLines, "billing_identity_unverified")).toHaveLength(
|
|
993
|
+
1,
|
|
994
|
+
);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// T3 (AC1 — a signal naming a different user binds that user, never the email's)
|
|
998
|
+
it("T3: a signal naming another user binds that user, never the email's", async () => {
|
|
999
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(
|
|
1000
|
+
neverBound("usr_attacker"),
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
const { statusCode } = await withLogCapture(
|
|
1004
|
+
checkoutCompleted("cus_attacker", { ailk_user_id: "usr_attacker" }),
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
expect(statusCode).toBe(200);
|
|
1008
|
+
expect(findUniqueWheres()).toEqual([{ id: "usr_attacker" }]);
|
|
1009
|
+
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
1010
|
+
expect(prisma.user.update).toHaveBeenCalledWith(
|
|
1011
|
+
expect.objectContaining({
|
|
1012
|
+
where: { id: "usr_attacker", billingCustomerId: null },
|
|
1013
|
+
}),
|
|
1014
|
+
);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// T4 (AC1 — client_reference_id is payer-settable, never a signal)
|
|
1018
|
+
it("T4: client_reference_id alone is not a signal", async () => {
|
|
1019
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(
|
|
1020
|
+
neverBound("usr_victim"),
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
1024
|
+
checkoutCompleted(
|
|
1025
|
+
"cus_attacker",
|
|
1026
|
+
{ productId: "pro" },
|
|
1027
|
+
{ client_reference_id: "usr_victim" },
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
expect(statusCode).toBe(200);
|
|
1032
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
1033
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
1034
|
+
expect(withOutcome(logLines, "billing_identity_unverified")).toHaveLength(
|
|
1035
|
+
1,
|
|
1036
|
+
);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// signalOf accepts only a non-empty string — anything else is no signal.
|
|
1040
|
+
it.each([
|
|
1041
|
+
["an empty string", ""],
|
|
1042
|
+
["a non-string", 42],
|
|
1043
|
+
])(
|
|
1044
|
+
"T4b: an ailk_user_id that is %s is not a signal",
|
|
1045
|
+
async (_label, value) => {
|
|
1046
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
1047
|
+
checkoutCompleted("cus_attacker", { ailk_user_id: value }),
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
expect(statusCode).toBe(200);
|
|
1051
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
1052
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
1053
|
+
expect(
|
|
1054
|
+
withOutcome(logLines, "billing_identity_unverified"),
|
|
1055
|
+
).toHaveLength(1);
|
|
1056
|
+
},
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
const buyerBind = {
|
|
1060
|
+
where: { id: "usr_buyer", billingCustomerId: null },
|
|
1061
|
+
data: { billingProvider: "stripe", billingCustomerId: "cus_buyer" },
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
// T5a (AC2 — a signed-in Checkout binds the buyer's first customer)
|
|
1065
|
+
it("T5a: checkout.session.completed carrying the signal binds the buyer", async () => {
|
|
1066
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(
|
|
1067
|
+
neverBound("usr_buyer"),
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
const { statusCode } = await withLogCapture(
|
|
1071
|
+
checkoutCompleted("cus_buyer", { ailk_user_id: "usr_buyer" }),
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
expect(statusCode).toBe(200);
|
|
1075
|
+
expect(findUniqueWheres()).toEqual([{ id: "usr_buyer" }]);
|
|
1076
|
+
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
1077
|
+
expect(prisma.user.update).toHaveBeenCalledWith(buyerBind);
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// T5b (AC2 — the signal is read from the customer on customer.created)
|
|
1081
|
+
it("T5b: customer.created carrying the signal binds the buyer", async () => {
|
|
1082
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(
|
|
1083
|
+
neverBound("usr_buyer"),
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
const { statusCode } = await withLogCapture(
|
|
1087
|
+
customerCreated("cus_buyer", null, { ailk_user_id: "usr_buyer" }),
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
expect(statusCode).toBe(200);
|
|
1091
|
+
expect(findUniqueWheres()).toEqual([{ id: "usr_buyer" }]);
|
|
1092
|
+
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
1093
|
+
expect(prisma.user.update).toHaveBeenCalledWith(buyerBind);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// T7 (F5 — a signal naming no User writes nothing and never echoes the value)
|
|
1097
|
+
it("T7: a signal naming no existing User binds nothing and is not logged", async () => {
|
|
1098
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce(null);
|
|
1099
|
+
|
|
1100
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
1101
|
+
checkoutCompleted("cus_buyer", { ailk_user_id: "usr_ghost" }),
|
|
1102
|
+
);
|
|
1103
|
+
|
|
1104
|
+
expect(statusCode).toBe(200);
|
|
1105
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
1106
|
+
const unknown = withOutcome(logLines, "billing_identity_unknown_user");
|
|
1107
|
+
expect(unknown).toHaveLength(1);
|
|
1108
|
+
expect(unknown[0]).toMatchObject({ level: WARN });
|
|
1109
|
+
expect(JSON.stringify(logLines)).not.toContain("usr_ghost");
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// T8 (F4 / SEC-04 — an ordinary purchase's customer.created is info, not warn)
|
|
1113
|
+
it("T8: a no-signal customer.created logs unverified at info", async () => {
|
|
1114
|
+
const { statusCode, logLines } = await withLogCapture(
|
|
1115
|
+
customerCreated("cus_buyer", null),
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
expect(statusCode).toBe(200);
|
|
1119
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
1120
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
1121
|
+
const unverified = withOutcome(logLines, "billing_identity_unverified");
|
|
1122
|
+
expect(unverified).toHaveLength(1);
|
|
1123
|
+
expect(unverified[0]).toMatchObject({
|
|
1124
|
+
level: INFO,
|
|
1125
|
+
eventType: "customer.created",
|
|
1126
|
+
});
|
|
1127
|
+
expect(JSON.stringify(logLines)).not.toContain("cus_buyer");
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
// T9 (F8 — organizationId keeps precedence over the user signal)
|
|
1131
|
+
it("T9: organizationId takes the Organization path even with a user signal", async () => {
|
|
1132
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
1133
|
+
|
|
1134
|
+
const { statusCode } = await withLogCapture(
|
|
1135
|
+
checkoutCompleted("cus_org", {
|
|
1136
|
+
organizationId: "org_1",
|
|
1137
|
+
ailk_user_id: "usr_buyer",
|
|
1138
|
+
}),
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1141
|
+
expect(statusCode).toBe(200);
|
|
1142
|
+
expect(prisma.organization.update).toHaveBeenCalledWith(
|
|
1143
|
+
expect.objectContaining({ where: { id: "org_1" } }),
|
|
1144
|
+
);
|
|
1145
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
1146
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
648
1149
|
});
|