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.
Files changed (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. 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
- database: prismaAdapter(prisma, {
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
  }
@@ -2,6 +2,7 @@
2
2
  // but no Vitest is installed anywhere yet). JSDOM env so RTL can mount components.
3
3
  module.exports = {
4
4
  preset: "ts-jest",
5
+ globalSetup: require.resolve("../../tools/jest/pin-timezone.cjs"),
5
6
  testEnvironment: "jsdom",
6
7
  roots: [
7
8
  "<rootDir>/__tests__",
@@ -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
+ });