create-ailk 0.20.0 → 0.22.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 +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The Stripe webhook route — the standard spoke checkout (#8748), kept current
|
|
2
|
+
// by `create-ailk update`: do not edit it. Every guarantee lives in the
|
|
3
|
+
// spoke-checkout package and arrives by a package bump. This site's checkout
|
|
4
|
+
// data is lib/checkout-config.ts, the one file to edit. It answers 404 on any
|
|
5
|
+
// build that is not a spoke.
|
|
6
|
+
import { createSpokeCheckout } from "@working-theory/spoke-checkout";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
checkoutNotServed,
|
|
10
|
+
checkoutServed,
|
|
11
|
+
spokeCheckoutConfig,
|
|
12
|
+
} from "../../../../lib/checkout";
|
|
13
|
+
|
|
14
|
+
export const runtime = "nodejs";
|
|
15
|
+
export const dynamic = "force-dynamic";
|
|
16
|
+
|
|
17
|
+
type WebhookRoute = ReturnType<typeof createSpokeCheckout>["webhook"];
|
|
18
|
+
|
|
19
|
+
let webhook: WebhookRoute | undefined;
|
|
20
|
+
|
|
21
|
+
/** Built on the first served request and kept for every later one. */
|
|
22
|
+
function webhookRoute(): WebhookRoute {
|
|
23
|
+
webhook ??= createSpokeCheckout(spokeCheckoutConfig).webhook;
|
|
24
|
+
return webhook;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// platform-decisions-waiver: api-versioning — the endpoint URL registered in Stripe, not a versioned API contract (spec 8748 §7)
|
|
28
|
+
export async function POST(request: Request): Promise<Response> {
|
|
29
|
+
if (!checkoutServed()) return checkoutNotServed();
|
|
30
|
+
return webhookRoute().POST(request);
|
|
31
|
+
}
|
|
@@ -43,6 +43,11 @@ import {
|
|
|
43
43
|
isSignUpOpen,
|
|
44
44
|
resetSendConfigured,
|
|
45
45
|
} from "./lib/auth-env";
|
|
46
|
+
import {
|
|
47
|
+
signUpAgreementHooks,
|
|
48
|
+
signUpAgreementsPlugin,
|
|
49
|
+
withAuthTransactionCapture,
|
|
50
|
+
} from "./lib/sign-up-agreements";
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* Reset-token lifetime (#5962 § D11). One hour — the library's own default,
|
|
@@ -438,9 +443,25 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
438
443
|
};
|
|
439
444
|
|
|
440
445
|
const instance = betterAuth({
|
|
441
|
-
|
|
446
|
+
/**
|
|
447
|
+
* `transaction: true` (#8765 D4): Better Auth opens one `$transaction` for
|
|
448
|
+
* exactly its three `runWithTransaction` sites — `/sign-up/email`,
|
|
449
|
+
* `createOAuthUser` and verification-token consumption — so a user, its
|
|
450
|
+
* account and its sign-up grants commit together or not at all. Every
|
|
451
|
+
* other write stays autocommit. The capture wrapper exposes that
|
|
452
|
+
* transaction's client to the sign-up hooks below; it changes nothing
|
|
453
|
+
* about the transaction itself (lib/sign-up-agreements.ts).
|
|
454
|
+
*/
|
|
455
|
+
database: prismaAdapter(withAuthTransactionCapture(prisma), {
|
|
442
456
|
provider: "postgresql",
|
|
457
|
+
transaction: true,
|
|
443
458
|
}),
|
|
459
|
+
/**
|
|
460
|
+
* The sign-up grants (#8765 D4): the user hook decides whether the new
|
|
461
|
+
* user carries agreements, the account hook writes them on the captured
|
|
462
|
+
* transaction client. No other database hook is installed.
|
|
463
|
+
*/
|
|
464
|
+
databaseHooks: signUpAgreementHooks(),
|
|
444
465
|
secret: resolveSecret(),
|
|
445
466
|
baseURL: process.env.BETTER_AUTH_URL,
|
|
446
467
|
socialProviders: buildSocialProviders(),
|
|
@@ -653,13 +674,18 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
653
674
|
* Sign-in only — the callback mails existing users and nobody else, and
|
|
654
675
|
* `disableSignUp` refuses to create one even for a live token
|
|
655
676
|
* (lib/auth-email.ts).
|
|
677
|
+
*
|
|
678
|
+
* `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
|
|
679
|
+
* `/sign-up/email` without every sign-up agreement is refused before the
|
|
680
|
+
* handler looks the address up.
|
|
656
681
|
*/
|
|
657
682
|
plugins: magicLinkActive
|
|
658
683
|
? [
|
|
659
684
|
nextCookies(),
|
|
685
|
+
signUpAgreementsPlugin(),
|
|
660
686
|
magicLink(makeMagicLinkOptions({ send: authEmailTransport })),
|
|
661
687
|
]
|
|
662
|
-
: [nextCookies()],
|
|
688
|
+
: [nextCookies(), signUpAgreementsPlugin()],
|
|
663
689
|
});
|
|
664
690
|
return instance;
|
|
665
691
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* account-invite.test.ts — #8757 D8 / AC-5: the /contact thank-you's account
|
|
3
|
+
* link, by the generated sign-in provider. A site signs in at its own
|
|
4
|
+
* locale-prefixed `/sign-in` and lands on its own `/account`; a hub-sign-in
|
|
5
|
+
* spoke starts at `/api/hub-session/sign-in`; a site with no sign-in gets no
|
|
6
|
+
* link, and neither does an email the hint schema refuses.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let mockProvider: "site" | "hub" | "none" = "site";
|
|
10
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER is a generated per-build constant (#8757 D8), not a runtime input; no env or HTTP surface selects it.
|
|
11
|
+
jest.mock("../topology.generated", () => ({
|
|
12
|
+
__esModule: true,
|
|
13
|
+
get SIGN_IN_PROVIDER() {
|
|
14
|
+
return mockProvider;
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { accountInviteHref } from "../account-invite";
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
mockProvider = "site";
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("accountInviteHref (#8757 D8)", () => {
|
|
25
|
+
it("site, the default locale: the bare /sign-in with next=/account and the email", () => {
|
|
26
|
+
expect(accountInviteHref("en", "a@b.example")).toBe(
|
|
27
|
+
"/sign-in?next=%2Faccount&email=a%40b.example",
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("site, another locale: both the sign-in path and next carry the prefix", () => {
|
|
32
|
+
expect(accountInviteHref("fr", "a@b.example")).toBe(
|
|
33
|
+
"/fr/sign-in?next=%2Ffr%2Faccount&email=a%40b.example",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("site: a plus sign in the address is encoded, never read as a space", () => {
|
|
38
|
+
expect(accountInviteHref("en", "a+b@x.example")).toBe(
|
|
39
|
+
"/sign-in?next=%2Faccount&email=a%2Bb%40x.example",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hub: the spoke's hub-session start, unprefixed", () => {
|
|
44
|
+
mockProvider = "hub";
|
|
45
|
+
expect(accountInviteHref("fr", "a@b.example")).toBe(
|
|
46
|
+
"/api/hub-session/sign-in?next=%2Faccount&email=a%40b.example",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("none: no link", () => {
|
|
51
|
+
mockProvider = "none";
|
|
52
|
+
expect(accountInviteHref("en", "a@b.example")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each(["x\r\n@y.example", "a&next=/evil@b.example", "not-an-email", ""])(
|
|
56
|
+
"an email the hint schema refuses (%j) gives no link, on either provider",
|
|
57
|
+
(email) => {
|
|
58
|
+
expect(accountInviteHref("en", email)).toBeNull();
|
|
59
|
+
mockProvider = "hub";
|
|
60
|
+
expect(accountInviteHref("en", email)).toBeNull();
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The standard spoke checkout (#8748 D6; spec
|
|
7
|
+
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-3, AC-4).
|
|
8
|
+
*
|
|
9
|
+
* `./checkout-config` — the site's own data — is mocked with a fixture in
|
|
10
|
+
* every case, so this suite passes against any shape of the site's file,
|
|
11
|
+
* including a pre-#8748 one that also carries `siteBaseUrl` and `fulfil`.
|
|
12
|
+
* The hub's `leads.submit` is the one external boundary mocked: the
|
|
13
|
+
* fulfilment must post one lead marked with the site, under the session id as
|
|
14
|
+
* its Idempotency-Key, with a body that stays byte-identical between attempts
|
|
15
|
+
* so the hub can replay it.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
const mockSubmit = jest.fn();
|
|
21
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
22
|
+
__esModule: true,
|
|
23
|
+
leads: { submit: (...args: unknown[]) => mockSubmit(...args) },
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const OFFERINGS = {
|
|
27
|
+
starter: { kind: "fixed", lookupKey: "acme_starter", cancelPath: "/" },
|
|
28
|
+
};
|
|
29
|
+
const mockSite: { value: Record<string, unknown> } = {
|
|
30
|
+
value: { site: "acme", offerings: OFFERINGS },
|
|
31
|
+
};
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the site's checkout-config.ts is the site's own data, not this suite's subject; a fixture stands in for it so the suite holds against any shape a site's file has (#8748 AC-2).
|
|
33
|
+
jest.mock("../checkout-config", () => ({
|
|
34
|
+
__esModule: true,
|
|
35
|
+
get checkoutConfig() {
|
|
36
|
+
return mockSite.value;
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
41
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
42
|
+
jest.mock("../topology.generated", () => ({
|
|
43
|
+
__esModule: true,
|
|
44
|
+
get TOPOLOGY() {
|
|
45
|
+
return mockTopology.value;
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
type CheckoutModule = typeof import("../checkout");
|
|
50
|
+
|
|
51
|
+
/** `lib/checkout.ts`, loaded fresh against the current site fixture. */
|
|
52
|
+
function load(site: Record<string, unknown> = { site: "acme", offerings: OFFERINGS }): CheckoutModule {
|
|
53
|
+
mockSite.value = site;
|
|
54
|
+
let loaded!: CheckoutModule;
|
|
55
|
+
jest.isolateModules(() => {
|
|
56
|
+
loaded = require("../checkout") as CheckoutModule;
|
|
57
|
+
});
|
|
58
|
+
return loaded;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A test-mode completion as the package hands it to `fulfil`. */
|
|
62
|
+
const COMPLETION = {
|
|
63
|
+
idempotencyKey: "cs_test_1",
|
|
64
|
+
sessionId: "cs_test_1",
|
|
65
|
+
offering: "starter",
|
|
66
|
+
kind: "fixed" as const,
|
|
67
|
+
mode: "test" as const,
|
|
68
|
+
livemode: false,
|
|
69
|
+
paymentStatus: "paid" as const,
|
|
70
|
+
amountTotal: 4900,
|
|
71
|
+
currency: "usd",
|
|
72
|
+
email: "b@example.com",
|
|
73
|
+
customerId: "cus_test_1",
|
|
74
|
+
termsOfService: null,
|
|
75
|
+
termsStamp: null,
|
|
76
|
+
metadata: { site: "acme", offering: "starter", kind: "fixed" },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `renderConfig`'s body for COMPLETION, key order included (NFR5), plus the
|
|
81
|
+
* session's terms consent as its last key (#8778).
|
|
82
|
+
*/
|
|
83
|
+
const EXPECTED_BODY =
|
|
84
|
+
'{"siteId":"acme","source":"web","email":"b@example.com","payload":{"site":"acme","page":"checkout","offering":"starter","checkoutKind":"fixed","mode":"test","amountTotal":4900,"currency":"usd","stripeCustomerId":"cus_test_1","checkoutSession":"cs_test_1","termsAccepted":null}}';
|
|
85
|
+
|
|
86
|
+
const SOURCE = readFileSync(join(__dirname, "..", "checkout.ts"), "utf8");
|
|
87
|
+
const ENV = process.env;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
mockSubmit.mockReset();
|
|
91
|
+
mockSubmit.mockResolvedValue({ ok: true, status: 201 });
|
|
92
|
+
process.env = { ...ENV, AILK_SPOKE_KEY: "spoke-key-fixture" };
|
|
93
|
+
mockTopology.value = "spoke";
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(() => {
|
|
97
|
+
process.env = ENV;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("spokeCheckoutConfig (#8748 AC-2, AC-4)", () => {
|
|
101
|
+
it("exports exactly spokeCheckoutConfig, checkoutServed and checkoutNotServed", () => {
|
|
102
|
+
expect(Object.keys(load()).sort()).toEqual([
|
|
103
|
+
"checkoutNotServed",
|
|
104
|
+
"checkoutServed",
|
|
105
|
+
"spokeCheckoutConfig",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("has exactly site, offerings, siteBaseUrl and fulfil, the offerings being the site's own object", () => {
|
|
110
|
+
const { spokeCheckoutConfig } = load();
|
|
111
|
+
expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
|
|
112
|
+
"fulfil",
|
|
113
|
+
"offerings",
|
|
114
|
+
"site",
|
|
115
|
+
"siteBaseUrl",
|
|
116
|
+
]);
|
|
117
|
+
expect(spokeCheckoutConfig.site).toBe("acme");
|
|
118
|
+
expect(spokeCheckoutConfig.offerings).toBe(OFFERINGS);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("takes only site and offerings from a site file that also carries env, siteBaseUrl and fulfil (SEC-8748-B)", async () => {
|
|
122
|
+
const siteFulfil = jest.fn();
|
|
123
|
+
const { spokeCheckoutConfig } = load({
|
|
124
|
+
site: "acme",
|
|
125
|
+
offerings: OFFERINGS,
|
|
126
|
+
env: { STRIPE_MODE: "live" },
|
|
127
|
+
siteBaseUrl: "https://elsewhere.example",
|
|
128
|
+
fulfil: siteFulfil,
|
|
129
|
+
});
|
|
130
|
+
expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
|
|
131
|
+
"fulfil",
|
|
132
|
+
"offerings",
|
|
133
|
+
"site",
|
|
134
|
+
"siteBaseUrl",
|
|
135
|
+
]);
|
|
136
|
+
expect("env" in spokeCheckoutConfig).toBe(false);
|
|
137
|
+
expect(spokeCheckoutConfig.siteBaseUrl).not.toBe("https://elsewhere.example");
|
|
138
|
+
expect(spokeCheckoutConfig.fulfil).not.toBe(siteFulfil);
|
|
139
|
+
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
140
|
+
expect(siteFulfil).not.toHaveBeenCalled();
|
|
141
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
|
|
145
|
+
expect(SOURCE).not.toContain("STRIPE_");
|
|
146
|
+
expect(SOURCE).not.toContain("stripe-client");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("checkoutServed / checkoutNotServed (#8748 D11)", () => {
|
|
151
|
+
it("serves on a spoke only", () => {
|
|
152
|
+
const { checkoutServed } = load();
|
|
153
|
+
mockTopology.value = "spoke";
|
|
154
|
+
expect(checkoutServed()).toBe(true);
|
|
155
|
+
mockTopology.value = "hub";
|
|
156
|
+
expect(checkoutServed()).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("answers 404 with no-store", () => {
|
|
160
|
+
const res = load().checkoutNotServed();
|
|
161
|
+
expect(res.status).toBe(404);
|
|
162
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("the fulfilment (#8748 AC-3)", () => {
|
|
167
|
+
it("posts one lead marked with the site, under the session id as its Idempotency-Key", async () => {
|
|
168
|
+
await load().spokeCheckoutConfig.fulfil(COMPLETION);
|
|
169
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
170
|
+
const [body, init] = mockSubmit.mock.calls[0] as [unknown, unknown];
|
|
171
|
+
expect(body).toEqual({
|
|
172
|
+
siteId: "acme",
|
|
173
|
+
source: "web",
|
|
174
|
+
email: "b@example.com",
|
|
175
|
+
payload: {
|
|
176
|
+
site: "acme",
|
|
177
|
+
page: "checkout",
|
|
178
|
+
offering: "starter",
|
|
179
|
+
checkoutKind: "fixed",
|
|
180
|
+
mode: "test",
|
|
181
|
+
amountTotal: 4900,
|
|
182
|
+
currency: "usd",
|
|
183
|
+
stripeCustomerId: "cus_test_1",
|
|
184
|
+
checkoutSession: "cs_test_1",
|
|
185
|
+
termsAccepted: null,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
expect(init).toEqual({ headers: { "Idempotency-Key": "cs_test_1" } });
|
|
189
|
+
expect(JSON.stringify(body)).toBe(EXPECTED_BODY);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("carries the stamp of the terms the buyer accepted as payload.termsAccepted (#8778)", async () => {
|
|
193
|
+
await load().spokeCheckoutConfig.fulfil({
|
|
194
|
+
...COMPLETION,
|
|
195
|
+
termsOfService: "accepted",
|
|
196
|
+
termsStamp: "account_terms@2",
|
|
197
|
+
});
|
|
198
|
+
const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
|
|
199
|
+
expect(body.payload["termsAccepted"]).toBe("account_terms@2");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it.each([
|
|
203
|
+
["a box not accepted", { termsOfService: null, termsStamp: "account_terms@2" }],
|
|
204
|
+
["an accepted box with no stamp", { termsOfService: "accepted" as const, termsStamp: null }],
|
|
205
|
+
])("sends termsAccepted: null for %s (#8778)", async (_label, terms) => {
|
|
206
|
+
await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, ...terms });
|
|
207
|
+
const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
|
|
208
|
+
expect(body.payload).toHaveProperty("termsAccepted", null);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("sends the same bytes under the same key on a replay", async () => {
|
|
212
|
+
const { spokeCheckoutConfig } = load();
|
|
213
|
+
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
214
|
+
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
215
|
+
expect(mockSubmit).toHaveBeenCalledTimes(2);
|
|
216
|
+
const [first, second] = mockSubmit.mock.calls as [
|
|
217
|
+
[unknown, unknown],
|
|
218
|
+
[unknown, unknown],
|
|
219
|
+
];
|
|
220
|
+
expect(JSON.stringify(second[0])).toBe(JSON.stringify(first[0]));
|
|
221
|
+
expect(second[1]).toEqual(first[1]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("makes no call for a completion without an email", async () => {
|
|
225
|
+
await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, email: null });
|
|
226
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects with no call when AILK_SPOKE_KEY is unset", async () => {
|
|
230
|
+
delete process.env["AILK_SPOKE_KEY"];
|
|
231
|
+
await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
|
|
232
|
+
"AILK_SPOKE_KEY is not set",
|
|
233
|
+
);
|
|
234
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("rejects when the hub answers { ok: false }", async () => {
|
|
238
|
+
mockSubmit.mockResolvedValue({ ok: false, status: 422 });
|
|
239
|
+
await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
|
|
240
|
+
"hub lead write answered 422",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The localized policy map (#8765 D9, AC-5): one map, the registry's keys,
|
|
3
|
+
* each path carrying the locale prefix off the default locale.
|
|
4
|
+
*/
|
|
5
|
+
import { consentPoliciesFor } from "../consent-policies";
|
|
6
|
+
|
|
7
|
+
describe("consentPoliciesFor", () => {
|
|
8
|
+
it("is /contact's three destinations for the default locale", () => {
|
|
9
|
+
expect(consentPoliciesFor("en")).toEqual({
|
|
10
|
+
terms: "/terms",
|
|
11
|
+
privacy: "/privacy",
|
|
12
|
+
working_agreement: "/working-agreement",
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("prefixes every path for another locale", () => {
|
|
17
|
+
expect(consentPoliciesFor("fr")).toEqual({
|
|
18
|
+
terms: "/fr/terms",
|
|
19
|
+
privacy: "/fr/privacy",
|
|
20
|
+
working_agreement: "/fr/working-agreement",
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deck-page resolver (#8687 D3, AC-2).
|
|
3
|
+
*
|
|
4
|
+
* `deckCandidates` and the `DECK_ROUTES` parity are pure functions over the
|
|
5
|
+
* real routes map. `resolveDeckPage` and `inputsFor` (captured off the
|
|
6
|
+
* `setDeckPages` registration call) are exercised with `getMdxSource` and
|
|
7
|
+
* `next/headers` mocked, in `renderPage-extension.test.tsx`'s idiom.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { ROUTES } from "@working-theory/templates/routes";
|
|
13
|
+
|
|
14
|
+
const mockGetMdxSource = jest.fn();
|
|
15
|
+
const mockSetDeckPages = jest.fn();
|
|
16
|
+
|
|
17
|
+
jest.mock("@working-theory/page-renderer", () => {
|
|
18
|
+
const actual = jest.requireActual("@working-theory/page-renderer");
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
getMdxSource: (...args: unknown[]) => mockGetMdxSource(...args),
|
|
22
|
+
setDeckPages: (...args: unknown[]) => mockSetDeckPages(...args),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const mockHeaders = jest.fn();
|
|
27
|
+
jest.mock("next/headers", () => ({ headers: () => mockHeaders() }));
|
|
28
|
+
|
|
29
|
+
const savedLocales = process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = "en,es";
|
|
33
|
+
});
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
if (savedLocales === undefined) delete process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
|
|
36
|
+
else process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = savedLocales;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
import { DECK_ROUTES, deckCandidates, resolveDeckPage } from "../deck-page";
|
|
40
|
+
import { siteChrome } from "../site-chrome";
|
|
41
|
+
|
|
42
|
+
describe("deckCandidates (#8687 D3, AC-2)", () => {
|
|
43
|
+
const LOCALES = ["en", "es"];
|
|
44
|
+
|
|
45
|
+
it("/pricing resolves the route row, then the [slug] fallback", () => {
|
|
46
|
+
expect(deckCandidates("/pricing", LOCALES)).toEqual([
|
|
47
|
+
{ type: "Pricing", slug: "pricing" },
|
|
48
|
+
{ type: "LandingPage", slug: "pricing" },
|
|
49
|
+
{ type: "BlogList", slug: "pricing" },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("/about (core, always scaffolded) resolves the route row only", () => {
|
|
54
|
+
expect(deckCandidates("/about", LOCALES)).toEqual([
|
|
55
|
+
{ type: "AboutPage", slug: "about" },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("/es/offer/ (no route match) falls through to [slug] alone", () => {
|
|
60
|
+
expect(deckCandidates("/es/offer/", LOCALES)).toEqual([
|
|
61
|
+
{ type: "LandingPage", slug: "offer" },
|
|
62
|
+
{ type: "BlogList", slug: "offer" },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("/projects/apollo resolves the detail row's directory type", () => {
|
|
67
|
+
expect(deckCandidates("/projects/apollo", LOCALES)).toEqual([
|
|
68
|
+
{ type: "Project", slug: "apollo" },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("/careers (gateable, no DECK_ROUTES entry) falls through to [slug]", () => {
|
|
73
|
+
expect(deckCandidates("/careers", LOCALES)).toEqual([
|
|
74
|
+
{ type: "LandingPage", slug: "careers" },
|
|
75
|
+
{ type: "BlogList", slug: "careers" },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each(["/", "/software", "/sign-in", "/blog/post", "/edit/LandingPage/x", "/a/b"])(
|
|
80
|
+
"%s resolves no candidates",
|
|
81
|
+
(pathname) => {
|
|
82
|
+
expect(deckCandidates(pathname, LOCALES)).toEqual([]);
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("DECK_ROUTES parity (#8687 D3)", () => {
|
|
88
|
+
it("contains every routes-map row with seed content whose page.tsx renders sections, minus /", () => {
|
|
89
|
+
// DECK_ROUTES is a fixed superset, not a per-scaffold exact set: a shape
|
|
90
|
+
// that doesn't ship one of these routes (e.g. a Free scaffold without
|
|
91
|
+
// /alternatives) simply has no page.tsx there, so deckCandidates/
|
|
92
|
+
// resolveDeckPage never get a request for it (harmless). What would be a
|
|
93
|
+
// real bug is the OTHER direction — an actually-shipped sections route
|
|
94
|
+
// this build's own tree carries that DECK_ROUTES fails to name.
|
|
95
|
+
const appRoot = path.join(__dirname, "..", "..", "app", "[locale]");
|
|
96
|
+
const sectionsRoutes = new Set<string>();
|
|
97
|
+
for (const row of ROUTES) {
|
|
98
|
+
if (row.route === "/" || !row.contentPath?.startsWith("content/en/")) continue;
|
|
99
|
+
const dir = row.route
|
|
100
|
+
.split("/")
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.map((segment) => (segment.startsWith(":") ? "[slug]" : segment))
|
|
103
|
+
.join("/");
|
|
104
|
+
let pageSource: string;
|
|
105
|
+
try {
|
|
106
|
+
pageSource = readFileSync(path.join(appRoot, dir, "page.tsx"), "utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!pageSource.includes('bodyMode: "mdx-body"')) {
|
|
111
|
+
sectionsRoutes.add(row.route);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const deckRoutesSet = new Set<string>(DECK_ROUTES);
|
|
115
|
+
const missing = [...sectionsRoutes].filter((route) => !deckRoutesSet.has(route));
|
|
116
|
+
expect(missing).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("resolveDeckPage (#8687 D3, AC-2)", () => {
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
mockGetMdxSource.mockReset();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns the first candidate with a source", async () => {
|
|
126
|
+
mockGetMdxSource.mockImplementation((type: string) =>
|
|
127
|
+
Promise.resolve(
|
|
128
|
+
type === "LandingPage"
|
|
129
|
+
? null
|
|
130
|
+
: type === "BlogList"
|
|
131
|
+
? { frontmatter: { type: "BlogList", slug: "offer" }, body: "" }
|
|
132
|
+
: null,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const result = await resolveDeckPage("/es/offer/", "en");
|
|
137
|
+
|
|
138
|
+
expect(result).toEqual({
|
|
139
|
+
type: "BlogList",
|
|
140
|
+
slug: "offer",
|
|
141
|
+
frontmatter: { type: "BlogList", slug: "offer" },
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null when a read throws", async () => {
|
|
146
|
+
mockGetMdxSource.mockRejectedValue(new Error("read failed"));
|
|
147
|
+
|
|
148
|
+
expect(await resolveDeckPage("/pricing", "en")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns null for a path with no candidates, with zero getMdxSource calls (NFR 2)", async () => {
|
|
152
|
+
expect(await resolveDeckPage("/software", "en")).toBeNull();
|
|
153
|
+
expect(mockGetMdxSource).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("the registered inputsFor (#8687 D3, AC-2)", () => {
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
mockGetMdxSource.mockReset();
|
|
160
|
+
mockHeaders.mockReset();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function registeredInputsFor(): (ctx: {
|
|
164
|
+
type: string;
|
|
165
|
+
slug: string;
|
|
166
|
+
locale: string;
|
|
167
|
+
}) => Promise<unknown> {
|
|
168
|
+
const call = mockSetDeckPages.mock.calls[mockSetDeckPages.mock.calls.length - 1];
|
|
169
|
+
return (
|
|
170
|
+
call?.[0] as {
|
|
171
|
+
inputsFor: (ctx: { type: string; slug: string; locale: string }) => Promise<unknown>;
|
|
172
|
+
}
|
|
173
|
+
).inputsFor;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it("resolves the design's inputs when the resolved page matches and opts in", async () => {
|
|
177
|
+
mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
|
|
178
|
+
mockGetMdxSource.mockImplementation((type: string) =>
|
|
179
|
+
Promise.resolve(
|
|
180
|
+
type === "Pricing"
|
|
181
|
+
? null
|
|
182
|
+
: type === "LandingPage"
|
|
183
|
+
? { frontmatter: { type: "LandingPage", slug: "pricing", layout: "deck" }, body: "" }
|
|
184
|
+
: null,
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const result = await registeredInputsFor()({
|
|
189
|
+
type: "LandingPage",
|
|
190
|
+
slug: "pricing",
|
|
191
|
+
locale: "en",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// The scaffold's generated site-chrome carries the DESIGN's values — minimalism
|
|
195
|
+
// presents the deck (deckPresentation true), standard does not — so the expected
|
|
196
|
+
// inputs are read from it rather than hard-coded to one design.
|
|
197
|
+
const chrome = siteChrome as typeof siteChrome & {
|
|
198
|
+
deckGrounds?: unknown;
|
|
199
|
+
deckPresentation?: true;
|
|
200
|
+
deckAnchors?: "bare";
|
|
201
|
+
};
|
|
202
|
+
expect(result).toEqual({
|
|
203
|
+
...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
|
|
204
|
+
deckPresentation: chrome.deckPresentation === true,
|
|
205
|
+
// #8773 — the design's slide naming rides along only when it presents and chose bare.
|
|
206
|
+
...(chrome.deckPresentation === true && chrome.deckAnchors === "bare"
|
|
207
|
+
? { deckAnchorStyle: "bare" }
|
|
208
|
+
: {}),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns null when the resolved page does not match this render", async () => {
|
|
213
|
+
mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
|
|
214
|
+
mockGetMdxSource.mockResolvedValue(
|
|
215
|
+
{ frontmatter: { type: "Pricing", slug: "pricing" }, body: "" },
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const result = await registeredInputsFor()({
|
|
219
|
+
type: "Pricing",
|
|
220
|
+
slug: "pricing",
|
|
221
|
+
locale: "en",
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(result).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns null for a resolved page without the field", async () => {
|
|
228
|
+
mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
|
|
229
|
+
mockGetMdxSource.mockImplementation((type: string) =>
|
|
230
|
+
Promise.resolve(
|
|
231
|
+
type === "LandingPage"
|
|
232
|
+
? { frontmatter: { type: "LandingPage", slug: "pricing" }, body: "" }
|
|
233
|
+
: null,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(
|
|
238
|
+
await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
|
|
239
|
+
).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("returns null with no x-pathname header", async () => {
|
|
243
|
+
mockHeaders.mockResolvedValue(new Map());
|
|
244
|
+
|
|
245
|
+
expect(
|
|
246
|
+
await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
|
|
247
|
+
).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
});
|