create-ailk 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/component-catalog.json +149 -5
  2. package/component-catalog.md +7 -6
  3. package/dist/add-module.js +11 -9
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +7 -5
  8. package/dist/derive-architecture.js +9 -32
  9. package/dist/lib/design-checks.js +4 -2
  10. package/dist/lib/env-catalog.js +0 -4
  11. package/dist/lib/npm-auth.d.ts +9 -0
  12. package/dist/lib/npm-auth.js +48 -0
  13. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  14. package/dist/lib/scaffold-render-sweep.js +5 -8
  15. package/dist/module-architecture.js +98 -10
  16. package/dist/paid-sections.d.ts +21 -2
  17. package/dist/paid-sections.js +69 -7
  18. package/dist/seed-flows.d.ts +4 -1
  19. package/dist/seed-flows.js +50 -4
  20. package/dist/spoke-checkout.d.ts +38 -35
  21. package/dist/spoke-checkout.js +66 -229
  22. package/dist/starter-grid.d.ts +0 -3
  23. package/dist/starter-grid.js +24 -14
  24. package/dist/surfaces.js +33 -12
  25. package/dist/sync-routes.d.ts +38 -1
  26. package/dist/sync-routes.js +184 -11
  27. package/dist/template-record.d.ts +1 -1
  28. package/dist/template-record.js +3 -2
  29. package/dist/update.js +10 -0
  30. package/package.json +3 -3
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  32. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  35. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  37. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  38. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  40. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  41. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  43. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/templates/.env.example +9 -7
  48. package/templates/.github/workflows/ci.yml +13 -0
  49. package/templates/README.md +1 -0
  50. package/templates/_gitignore +4 -0
  51. package/templates/apps/api/.env.example +27 -0
  52. package/templates/apps/api/CLAUDE.md +77 -16
  53. package/templates/apps/api/package.json +1 -0
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  55. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  56. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  57. package/templates/apps/api/src/config/index.ts +109 -0
  58. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  59. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  60. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  61. package/templates/apps/api/src/lib/stripe.ts +74 -4
  62. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  63. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  64. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  65. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  66. package/templates/apps/api/src/openapi/spec.ts +10 -7
  67. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  68. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  69. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  70. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  71. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  72. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  73. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  74. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  75. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  76. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  77. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  78. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  79. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  80. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  81. package/templates/apps/api/src/routes/content/index.ts +16 -2
  82. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  83. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  84. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  85. package/templates/apps/api/src/routes/leads/index.ts +17 -14
  86. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  87. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  88. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  89. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  90. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  91. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  92. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  93. package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
  94. package/templates/apps/api/src/server.ts +140 -51
  95. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  96. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  97. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  98. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  99. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  100. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  101. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  102. package/templates/apps/api/src/services/entitlements.ts +157 -1
  103. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  104. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  105. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  106. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  107. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  108. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  109. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  110. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  111. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  112. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  113. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  114. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  115. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  116. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  117. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  118. package/templates/apps/api/src/services/person.ts +124 -9
  119. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  120. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  121. package/templates/apps/mcp/CLAUDE.md +22 -14
  122. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  123. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  124. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  125. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  126. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  127. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  128. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  129. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  130. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  131. package/templates/apps/mcp/src/server.ts +5 -0
  132. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  133. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  134. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  135. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  136. package/templates/apps/web/.env.example +10 -31
  137. package/templates/apps/web/CLAUDE.md +315 -42
  138. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  139. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  140. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  141. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  142. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  143. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  144. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  145. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  146. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  147. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  148. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  149. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  150. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  151. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  152. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  153. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  154. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
  155. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  156. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  157. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  158. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  159. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  160. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  161. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  162. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  163. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  164. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  165. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  166. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  167. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  168. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  169. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  170. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  171. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  172. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  173. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  174. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  175. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  176. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  177. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  178. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  179. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  180. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  181. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  182. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  183. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  184. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  185. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  187. package/templates/apps/web/lib/auth-routes.ts +36 -2
  188. package/templates/apps/web/lib/checkout-config.ts +9 -20
  189. package/templates/apps/web/lib/deck-page.ts +1 -0
  190. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  191. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  192. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  195. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  196. package/templates/apps/web/lib/posting-title.ts +17 -0
  197. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  198. package/templates/apps/web/messages/en.json +49 -15
  199. package/templates/apps/web/middleware.ts +12 -2
  200. package/templates/apps/web/security-headers.config.ts +17 -0
  201. package/templates/content/en/BlogList/blog.mdx +4 -71
  202. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  203. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  204. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  205. package/templates/content/en/BookCall/start-project.mdx +1 -17
  206. package/templates/content/en/ContactPage/contact.mdx +6 -63
  207. package/templates/content/en/HomePage/home.mdx +3 -21
  208. package/templates/database/CHANGELOG.md +31 -0
  209. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  210. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  211. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  212. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  213. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  214. package/templates/database/__tests__/seed.test.ts +31 -1
  215. package/templates/database/auth/schema.prisma +4 -0
  216. package/templates/database/config/schema.prisma +91 -0
  217. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  218. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  219. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  220. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  221. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  222. package/templates/database/package.json +1 -1
  223. package/templates/database/seed.ts +30 -2
  224. package/versions.json +35 -35
  225. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  226. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  227. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  228. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  229. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  230. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  231. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  232. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  233. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  234. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  235. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  236. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  237. package/templates/apps/web/lib/checkout.ts +0 -93
  238. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,197 @@
1
+ // oss: false
2
+ /**
3
+ * #8840 D9, AC-3 — FlowStepperClient's standard-magnet wiring: the locale's
4
+ * policy links, the input-step count, and the flow's own ending (a screen in
5
+ * the finished card or a redirect to a site path, never `thankYou` once an
6
+ * ending exists). FlowStepper is a stub that records its props, submits the
7
+ * configured steps through the real `onSubmitStep`, and renders the
8
+ * `completedContent` it is given — as the invite test's stub does.
9
+ */
10
+ import { render, screen, waitFor } from "@testing-library/react";
11
+ import userEvent from "@testing-library/user-event";
12
+ import type { FlowConfig } from "@working-theory/validation";
13
+ import type { ReactElement, ReactNode } from "react";
14
+
15
+ const mockPush = jest.fn();
16
+ jest.mock("next/navigation", () => ({
17
+ useRouter: () => ({ push: mockPush }),
18
+ }));
19
+
20
+ jest.mock("next-intl", () => ({
21
+ useTranslations: () => (key: string) => key,
22
+ useMessages: () => ({}),
23
+ }));
24
+
25
+ // eslint-disable-next-line no-restricted-syntax -- the boundary under test is the client wiring, not the actions
26
+ jest.mock("../flow.actions", () => ({ submitFlowStepAction: jest.fn() }));
27
+ // eslint-disable-next-line no-restricted-syntax -- see above
28
+ jest.mock("../account-invite.actions", () => ({ accountInviteLinkAction: jest.fn() }));
29
+
30
+ type Step = { stepKey: string; values: Record<string, unknown> };
31
+ let steps: Step[] = [];
32
+
33
+ interface StubProps {
34
+ onSubmitStep: (payload: Step) => Promise<{ ok: boolean; response?: { completed: boolean } }>;
35
+ onCompleted?: unknown;
36
+ uncountedStepKeys?: readonly string[];
37
+ consentConfig?: { policies: Record<string, string> };
38
+ completedContent?: ReactNode;
39
+ hiddenValues?: Record<string, string>;
40
+ header?: ReactNode;
41
+ }
42
+ let lastProps: StubProps | undefined;
43
+
44
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
45
+ FlowStepper: (props: StubProps) => {
46
+ lastProps = props;
47
+ async function handleSubmit(): Promise<void> {
48
+ for (const step of steps) await props.onSubmitStep(step);
49
+ }
50
+ const react = require("react");
51
+ return react.createElement(
52
+ "div",
53
+ { "data-testid": "mock-card" },
54
+ react.createElement("button", { "data-testid": "mock-submit", onClick: handleSubmit }, "Submit"),
55
+ props.completedContent,
56
+ ) as ReactElement;
57
+ },
58
+ }));
59
+
60
+ import { submitFlowStepAction } from "../flow.actions";
61
+ import { FlowStepperClient } from "../FlowStepperClient";
62
+
63
+ const mockSubmit = submitFlowStepAction as jest.MockedFunction<typeof submitFlowStepAction>;
64
+ const en = (s: string) => ({ en: s });
65
+
66
+ function baseConfig(extra: Partial<FlowConfig> = {}): FlowConfig {
67
+ return {
68
+ slug: "growth",
69
+ defaultLocale: "en",
70
+ steps: [
71
+ {
72
+ key: "about",
73
+ type: "field",
74
+ copy: { title: en("About you") },
75
+ fields: [
76
+ { name: "first_name", inputType: "text", mapsTo: "givenName", copy: { label: en("First name") } },
77
+ { name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: en("Email") } },
78
+ ],
79
+ },
80
+ {
81
+ key: "pace",
82
+ type: "segmentation",
83
+ copy: { title: en("Pace?") },
84
+ questions: [{ name: "pace", options: [{ value: "fast", label: en("Fast") }, { value: "slow", label: en("Slow") }] }],
85
+ },
86
+ { key: "thanks", type: "message", copy: { title: en("Thanks") } },
87
+ ],
88
+ ...extra,
89
+ } as FlowConfig;
90
+ }
91
+
92
+ function completeOn(lastKey: string): void {
93
+ mockSubmit.mockImplementation(async (_slug, payload) => ({
94
+ ok: true,
95
+ response: {
96
+ sessionId: "s1",
97
+ landed: payload.stepKey,
98
+ nextStepKey: null,
99
+ completed: payload.stepKey === lastKey,
100
+ },
101
+ }));
102
+ }
103
+
104
+ beforeEach(() => {
105
+ jest.clearAllMocks();
106
+ lastProps = undefined;
107
+ steps = [
108
+ { stepKey: "about", values: { first_name: "Ana", email: "ana@example.com" } },
109
+ { stepKey: "pace", values: { pace: "fast" } },
110
+ ];
111
+ completeOn("pace");
112
+ });
113
+
114
+ async function walk(config: FlowConfig, locale = "en"): Promise<void> {
115
+ render(<FlowStepperClient config={config} locale={locale} slug={config.slug} />);
116
+ await userEvent.setup().click(screen.getByTestId("mock-submit"));
117
+ }
118
+
119
+ describe("the count and the registry (#8840 D9)", () => {
120
+ it("passes every message step's key as uncounted", () => {
121
+ render(<FlowStepperClient config={baseConfig()} locale="en" slug="growth" />);
122
+ expect(lastProps?.uncountedStepKeys).toEqual(["thanks"]);
123
+ });
124
+
125
+ it("localizes the policy links", () => {
126
+ render(<FlowStepperClient config={baseConfig()} locale="fr" slug="growth" />);
127
+ expect(lastProps?.consentConfig?.policies["terms"]).toBe("/fr/terms");
128
+ expect(lastProps?.consentConfig?.policies["privacy"]).toBe("/fr/privacy");
129
+ });
130
+
131
+ it("passes hiddenValues and header through", () => {
132
+ render(
133
+ <FlowStepperClient
134
+ config={baseConfig()}
135
+ locale="en"
136
+ slug="growth"
137
+ hiddenValues={{ job: "writer" }}
138
+ header={<span>Back</span>}
139
+ />,
140
+ );
141
+ expect(lastProps?.hiddenValues).toEqual({ job: "writer" });
142
+ expect(lastProps?.header).toBeDefined();
143
+ });
144
+ });
145
+
146
+ describe("the ending wins (#8840 D9)", () => {
147
+ const screenEnding = {
148
+ default: {
149
+ kind: "screen" as const,
150
+ headline: en("Thanks, {name}."),
151
+ body: en("We'll be in touch."),
152
+ ctas: [
153
+ { label: en("Book a call"), href: "/meet/intro" },
154
+ // Injected past the schema: the client re-checks every destination.
155
+ { label: en("Elsewhere"), href: "//evil.example" },
156
+ ],
157
+ },
158
+ };
159
+
160
+ it("with an ending and a thankYou, passes no onCompleted", () => {
161
+ render(
162
+ <FlowStepperClient
163
+ config={baseConfig({ ending: screenEnding, thankYou: { default: "thanks" } })}
164
+ locale="en"
165
+ slug="growth"
166
+ />,
167
+ );
168
+ expect(lastProps?.onCompleted).toBeUndefined();
169
+ });
170
+
171
+ it("a completing response renders the filled headline and the localized on-site CTA only", async () => {
172
+ await walk(baseConfig({ ending: screenEnding, thankYou: { default: "thanks" } }), "fr");
173
+ await screen.findByTestId("flow-ending");
174
+ expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Thanks, Ana.");
175
+ const links = screen.getAllByTestId("flow-ending-cta");
176
+ expect(links.map((a) => a.getAttribute("href"))).toEqual(["/fr/meet/intro"]);
177
+ expect(mockPush).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it("a redirect ending pushes the locale-prefixed site path", async () => {
181
+ await walk(baseConfig({ ending: { default: { kind: "redirect", href: "/pricing?plan=pro" } } }));
182
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/en/pricing?plan=pro"));
183
+ });
184
+
185
+ it("a redirect href injected past the schema is never pushed", async () => {
186
+ await walk(baseConfig({ ending: { default: { kind: "redirect", href: "//evil.example" } } }));
187
+ await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(2));
188
+ expect(mockPush).not.toHaveBeenCalled();
189
+ expect(screen.queryByTestId("flow-ending")).toBeNull();
190
+ });
191
+
192
+ it("with no ending, nothing new renders and thankYou still navigates", async () => {
193
+ await walk(baseConfig({ thankYou: { default: "thanks" } }));
194
+ expect(lastProps?.completedContent).toBeUndefined();
195
+ expect(typeof lastProps?.onCompleted).toBe("function");
196
+ });
197
+ });
@@ -0,0 +1,430 @@
1
+ // oss: false
2
+ /**
3
+ * #8840 D8 — the standard magnet page pair (`magnet-page.tsx`).
4
+ *
5
+ * AC-4 — the landing renders the flow record's `landing` copy (one H1, the
6
+ * line, the meta line, a CTA to `<path>/start`) with the `Assessment`
7
+ * page type's `WebApplication` JSON-LD and no robots key, falling back
8
+ * to step 1's title and the generic `flow` copy; the walk renders the
9
+ * back arrow and the stepper on step 1 and is `noindex, follow`.
10
+ * AC-5 — an unreachable hub (a rejected read or a 5xx) shows the site's
11
+ * contact email on both pages, both `noindex`, and nothing throws; a
12
+ * 404 is `notFound()`; one slug's read is shared for 15 seconds, and a
13
+ * slug the schema refuses is never read.
14
+ */
15
+ import { render, screen } from "@testing-library/react";
16
+ import type { FlowConfig } from "@working-theory/validation";
17
+ import type { ReactElement } from "react";
18
+
19
+ const mockGet = jest.fn();
20
+ jest.mock("@working-theory/api-client", () => ({
21
+ __esModule: true,
22
+ flows: { get: (...args: unknown[]) => mockGet(...args) },
23
+ }));
24
+
25
+ const mockRedirect = jest.fn((href: string) => {
26
+ throw new Error(`NEXT_REDIRECT ${href}`);
27
+ });
28
+ jest.mock("next/navigation", () => ({
29
+ __esModule: true,
30
+ notFound: () => {
31
+ throw new Error("NEXT_NOT_FOUND");
32
+ },
33
+ redirect: (href: string) => mockRedirect(href),
34
+ useRouter: () => ({ push: jest.fn() }),
35
+ }));
36
+
37
+ jest.mock("next-intl/server", () => ({
38
+ __esModule: true,
39
+ getTranslations: async () => (key: string) => key,
40
+ }));
41
+
42
+ // The hero section's data, drawn as plain landmarks the assertions read.
43
+ jest.mock("@working-theory/ui/sections", () => {
44
+ const react = require("react");
45
+ return {
46
+ __esModule: true,
47
+ SectionRenderer: ({ sections }: { sections: Array<{ data: Record<string, unknown> }> }) => {
48
+ const data = sections[0]!.data as {
49
+ headline: string;
50
+ subheadline: string;
51
+ body?: string;
52
+ variant?: string;
53
+ primaryCta: { label: string; href: string };
54
+ };
55
+ return react.createElement(
56
+ "section",
57
+ { "data-variant": data.variant },
58
+ react.createElement("h1", null, data.headline),
59
+ react.createElement("p", { "data-testid": "hero-line" }, data.subheadline),
60
+ data.body !== undefined
61
+ ? react.createElement("p", { "data-testid": "hero-meta" }, data.body)
62
+ : null,
63
+ react.createElement("a", { href: data.primaryCta.href, "data-testid": "hero-cta" }, data.primaryCta.label),
64
+ );
65
+ },
66
+ };
67
+ });
68
+
69
+ const mockStepper = jest.fn();
70
+ // eslint-disable-next-line no-restricted-syntax -- the walk's client wiring has its own suites; this file pins the renderer
71
+ jest.mock("../FlowStepperClient", () => ({
72
+ FlowStepperClient: (props: Record<string, unknown>) => {
73
+ mockStepper(props);
74
+ const react = require("react");
75
+ return react.createElement(
76
+ "div",
77
+ { "data-testid": "stepper" },
78
+ props["header"] as ReactElement,
79
+ (props["config"] as FlowConfig).steps[0]!.copy.title["en"],
80
+ );
81
+ },
82
+ }));
83
+
84
+ // eslint-disable-next-line no-restricted-syntax -- the contact email is env-backed; its reader has its own suite
85
+ jest.mock("../../../contact/site-contact-email", () => ({
86
+ readSiteContactEmail: () => "hello@example.com",
87
+ }));
88
+
89
+ import {
90
+ magnetLandingMetadata,
91
+ magnetStartMetadata,
92
+ readMagnet,
93
+ renderMagnetLanding,
94
+ renderMagnetStart,
95
+ renderMagnetStartMetadata,
96
+ } from "../magnet-page";
97
+
98
+ const en = (s: string) => ({ en: s });
99
+
100
+ function config(withLanding = true): FlowConfig {
101
+ return {
102
+ slug: "growth",
103
+ defaultLocale: "en",
104
+ ...(withLanding
105
+ ? {
106
+ landing: {
107
+ title: en("How ready is your team?"),
108
+ description: en("Ten questions, one page of results."),
109
+ meta: en("About 3 minutes · Your result arrives by email"),
110
+ cta: en("Start the diagnostic"),
111
+ },
112
+ }
113
+ : {}),
114
+ steps: [
115
+ {
116
+ key: "team",
117
+ type: "field",
118
+ copy: { title: en("Who are you?") },
119
+ fields: [{ name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: en("Email") } }],
120
+ },
121
+ ],
122
+ } as FlowConfig;
123
+ }
124
+
125
+ // One slug's read is shared for 15 seconds; each test starts an hour later.
126
+ let clock = 1_800_000_000_000;
127
+ jest.spyOn(Date, "now").mockImplementation(() => clock);
128
+
129
+ beforeEach(() => {
130
+ clock += 60 * 60 * 1000;
131
+ mockGet.mockReset();
132
+ mockStepper.mockReset();
133
+ mockGet.mockResolvedValue({ ok: true, body: config() });
134
+ });
135
+
136
+ const params = Promise.resolve({ locale: "en" });
137
+ const REF = { slug: "growth", path: "/diagnostic" };
138
+
139
+ describe("the landing (#8840 D8, AC-4)", () => {
140
+ it("renders the record's landing copy, a CTA to <path>/start and the WebApplication JSON-LD", async () => {
141
+ const { container } = render(
142
+ await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }),
143
+ );
144
+ expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
145
+ expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("How ready is your team?");
146
+ expect(screen.getByTestId("hero-line").textContent).toBe("Ten questions, one page of results.");
147
+ expect(screen.getByTestId("hero-meta").textContent).toBe(
148
+ "About 3 minutes · Your result arrives by email",
149
+ );
150
+ expect(screen.getByTestId("hero-cta").getAttribute("href")).toBe("/diagnostic/start");
151
+ expect(screen.getByTestId("hero-cta").textContent).toBe("Start the diagnostic");
152
+ expect(container.querySelector("section")?.getAttribute("data-variant")).toBe("centered");
153
+
154
+ const script = container.querySelector('script[type="application/ld+json"]');
155
+ const jsonLd = JSON.parse(script!.textContent!) as { "@type": string; name: string };
156
+ expect(jsonLd["@type"]).toBe("WebApplication");
157
+ expect(jsonLd.name).toBe("How ready is your team?");
158
+ });
159
+
160
+ it("localizes the CTA for a non-default locale", async () => {
161
+ render(await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "fr" }));
162
+ expect(screen.getByTestId("hero-cta").getAttribute("href")).toBe("/fr/diagnostic/start");
163
+ });
164
+
165
+ it("falls back to step 1's title, flow.landingIntro and flow.getStarted, with no meta line", async () => {
166
+ mockGet.mockResolvedValue({ ok: true, body: config(false) });
167
+ render(await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }));
168
+ expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Who are you?");
169
+ expect(screen.getByTestId("hero-line").textContent).toBe("landingIntro");
170
+ expect(screen.getByTestId("hero-cta").textContent).toBe("getStarted");
171
+ expect(screen.queryByTestId("hero-meta")).toBeNull();
172
+ });
173
+
174
+ it("is indexable: the metadata carries the title and no robots key", async () => {
175
+ const metadata = await magnetLandingMetadata(REF)({ params });
176
+ expect(metadata.robots).toBeUndefined();
177
+ expect(metadata.title).toBe("How ready is your team?");
178
+ });
179
+ });
180
+
181
+ describe("the walk (#8840 D8, AC-4)", () => {
182
+ it("renders a NavBackLink to the landing and the stepper on step 1", async () => {
183
+ render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
184
+ expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe("/diagnostic");
185
+ expect(screen.getByTestId("stepper").textContent).toContain("Who are you?");
186
+ const props = mockStepper.mock.calls[0]![0] as Record<string, unknown>;
187
+ expect(props["slug"]).toBe("growth");
188
+ expect(props).not.toHaveProperty("hiddenValues");
189
+ });
190
+
191
+ it("replaces step 1's title and passes hidden values when asked", async () => {
192
+ render(
193
+ await renderMagnetStart({
194
+ slug: "growth",
195
+ locale: "en",
196
+ backHref: "/careers/writer",
197
+ hiddenValues: { job: "writer" },
198
+ firstStepTitle: "Writer",
199
+ }),
200
+ );
201
+ const props = mockStepper.mock.calls[0]![0] as { config: FlowConfig; hiddenValues: unknown };
202
+ expect(props.config.steps[0]!.copy.title).toEqual({ en: "Writer" });
203
+ expect(props.hiddenValues).toEqual({ job: "writer" });
204
+ });
205
+
206
+ it("is noindex, follow", async () => {
207
+ const metadata = await magnetStartMetadata(REF)({ params });
208
+ expect(metadata.robots).toEqual({ index: false, follow: true });
209
+ });
210
+ });
211
+
212
+ describe("an unreachable hub shows the contact email (#8840 D8, AC-5)", () => {
213
+ // #9219 — a configured hub, so a rejected read or a plain 503 is an outage
214
+ // (`unavailable`), not a site with no hub to ask.
215
+ let savedOrigin: string | undefined;
216
+ beforeEach(() => {
217
+ savedOrigin = process.env.AILK_API_URL;
218
+ process.env.AILK_API_URL = "https://hub.example";
219
+ });
220
+ afterEach(() => {
221
+ if (savedOrigin === undefined) delete process.env.AILK_API_URL;
222
+ else process.env.AILK_API_URL = savedOrigin;
223
+ });
224
+
225
+ it.each([
226
+ ["a rejected read", () => mockGet.mockRejectedValue(new TypeError("fetch failed"))],
227
+ ["a 503", () => mockGet.mockResolvedValue({ ok: false, status: 503, body: null })],
228
+ // #9219 — a hub that is not configured is its own read kind, and a magnet
229
+ // pair treats it exactly as an outage.
230
+ [
231
+ "a 503 flows_not_configured",
232
+ () =>
233
+ mockGet.mockResolvedValue({
234
+ ok: false,
235
+ status: 503,
236
+ body: { ok: false, error: { code: "flows_not_configured", message: "x" } },
237
+ }),
238
+ ],
239
+ ])("%s: both pages render ContactFallback and both are noindex", async (_label, arrange) => {
240
+ arrange();
241
+ const landing = render(
242
+ await renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }),
243
+ );
244
+ expect(screen.getByTestId("contact-fallback").textContent).toContain("hello@example.com");
245
+ expect(screen.getByTestId("contact-fallback").textContent).toContain("unavailableHeading");
246
+ landing.unmount();
247
+
248
+ render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
249
+ expect(screen.getByTestId("contact-fallback").textContent).toContain("hello@example.com");
250
+ expect(screen.getByTestId("nav-back-link")).toBeTruthy();
251
+ expect(mockStepper).not.toHaveBeenCalled();
252
+
253
+ expect((await magnetLandingMetadata(REF)({ params })).robots).toEqual({ index: false, follow: true });
254
+ expect((await magnetStartMetadata(REF)({ params })).robots).toEqual({ index: false, follow: true });
255
+ });
256
+
257
+ it("a 404 is notFound() on both pages, not an outage", async () => {
258
+ mockGet.mockResolvedValue({ ok: false, status: 404, body: null });
259
+ await expect(
260
+ renderMagnetLanding({ slug: "growth", path: "/diagnostic", locale: "en" }),
261
+ ).rejects.toThrow("NEXT_NOT_FOUND");
262
+ await expect(
263
+ renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }),
264
+ ).rejects.toThrow("NEXT_NOT_FOUND");
265
+ });
266
+ });
267
+
268
+ describe("a walk that exists apart from its flow passes a title (#9219)", () => {
269
+ const titled = { slug: "growth", locale: "en", backHref: "/careers", title: "Job application" };
270
+
271
+ it.each([
272
+ ["a 404", () => mockGet.mockResolvedValue({ ok: false, status: 404, body: null })],
273
+ ["a rejected read", () => mockGet.mockRejectedValue(new TypeError("fetch failed"))],
274
+ ["a 503", () => mockGet.mockResolvedValue({ ok: false, status: 503, body: null })],
275
+ ])("%s: the contact email under the page's own <h1>, never a 404", async (_label, arrange) => {
276
+ arrange();
277
+ render(await renderMagnetStart(titled));
278
+ expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Job application");
279
+ expect(screen.getByTestId("contact-fallback").textContent).toContain("hello@example.com");
280
+ expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe("/careers");
281
+ expect(mockStepper).not.toHaveBeenCalled();
282
+ });
283
+
284
+ it("the fallback's metadata carries the title and stays noindex", async () => {
285
+ mockGet.mockRejectedValue(new TypeError("fetch failed"));
286
+ const metadata = await renderMagnetStartMetadata({ slug: "growth", locale: "en", title: "Job application" });
287
+ expect(metadata).toEqual({ title: "Job application", robots: { index: false, follow: true } });
288
+ });
289
+
290
+ it("a served flow still walks, with no extra heading", async () => {
291
+ render(await renderMagnetStart(titled));
292
+ expect(mockStepper).toHaveBeenCalledTimes(1);
293
+ expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
294
+ });
295
+
296
+ it("without a title an unreachable hub renders no <h1> of its own, and a 404 stays notFound()", async () => {
297
+ mockGet.mockRejectedValue(new TypeError("fetch failed"));
298
+ render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
299
+ expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
300
+
301
+ clock += 60 * 60 * 1000;
302
+ mockGet.mockResolvedValue({ ok: false, status: 404, body: null });
303
+ await expect(
304
+ renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }),
305
+ ).rejects.toThrow("NEXT_NOT_FOUND");
306
+ });
307
+ });
308
+
309
+ describe("the shared read (#8840 D8, AC-5)", () => {
310
+ it("two calls within 15 seconds make one flows.get call", async () => {
311
+ await readMagnet("growth");
312
+ clock += 14_000;
313
+ await readMagnet("growth");
314
+ expect(mockGet).toHaveBeenCalledTimes(1);
315
+ clock += 2_000;
316
+ await readMagnet("growth");
317
+ expect(mockGet).toHaveBeenCalledTimes(2);
318
+ });
319
+
320
+ it("holds at most 256 slugs: past the cap a read is made but not shared", async () => {
321
+ for (let i = 0; i < 300; i += 1) await readMagnet(`slug-${i}`);
322
+ expect(mockGet).toHaveBeenCalledTimes(300);
323
+ await readMagnet("slug-0");
324
+ expect(mockGet).toHaveBeenCalledTimes(300);
325
+ await readMagnet("slug-299");
326
+ expect(mockGet).toHaveBeenCalledTimes(301);
327
+ });
328
+
329
+ it("a slug the schema refuses is absent with no read", async () => {
330
+ await expect(readMagnet("Bad_Slug")).resolves.toEqual({ kind: "absent" });
331
+ await expect(readMagnet("Bad_Slug")).resolves.toEqual({ kind: "absent" });
332
+ expect(mockGet).not.toHaveBeenCalled();
333
+ });
334
+ });
335
+
336
+ describe("the read tells a hub that is not configured from one that is down (#9219)", () => {
337
+ const ORIGIN_NAMES = ["AILK_API_URL", "NEXT_PUBLIC_AILK_API_URL", "NEXT_PUBLIC_API_URL"] as const;
338
+ const saved: Record<string, string | undefined> = {};
339
+ beforeEach(() => {
340
+ for (const name of ORIGIN_NAMES) {
341
+ saved[name] = process.env[name];
342
+ delete process.env[name];
343
+ }
344
+ });
345
+ afterEach(() => {
346
+ for (const name of ORIGIN_NAMES) {
347
+ if (saved[name] === undefined) delete process.env[name];
348
+ else process.env[name] = saved[name];
349
+ }
350
+ });
351
+
352
+ const notConfigured503 = {
353
+ ok: false,
354
+ status: 503,
355
+ body: { ok: false, error: { code: "flows_not_configured", message: "x" } },
356
+ };
357
+
358
+ it("a 503 flows_not_configured is not-configured, origin set or not", async () => {
359
+ process.env.AILK_API_URL = "https://hub.example";
360
+ mockGet.mockResolvedValue(notConfigured503);
361
+ await expect(readMagnet("growth")).resolves.toEqual({ kind: "not-configured" });
362
+ });
363
+
364
+ it.each(ORIGIN_NAMES)("with %s set, a 5xx or a throw is unavailable", async (name) => {
365
+ process.env[name] = "https://hub.example";
366
+ mockGet.mockResolvedValue({ ok: false, status: 503, body: null });
367
+ await expect(readMagnet("growth")).resolves.toEqual({ kind: "unavailable" });
368
+ clock += 60_000;
369
+ mockGet.mockRejectedValue(new TypeError("fetch failed"));
370
+ await expect(readMagnet("growth")).resolves.toEqual({ kind: "unavailable" });
371
+ });
372
+
373
+ it("with no API origin, a 5xx or a throw is not-configured", async () => {
374
+ mockGet.mockResolvedValue({ ok: false, status: 502, body: null });
375
+ await expect(readMagnet("growth")).resolves.toEqual({ kind: "not-configured" });
376
+ clock += 60_000;
377
+ mockGet.mockRejectedValue(new TypeError("fetch failed"));
378
+ await expect(readMagnet("growth")).resolves.toEqual({ kind: "not-configured" });
379
+ });
380
+
381
+ it("with no API origin, a 404 is still absent and a served flow still ok", async () => {
382
+ mockGet.mockResolvedValue({ ok: false, status: 404, body: null });
383
+ await expect(readMagnet("growth")).resolves.toEqual({ kind: "absent" });
384
+ clock += 60_000;
385
+ mockGet.mockResolvedValue({ ok: true, body: config() });
386
+ await expect(readMagnet("growth")).resolves.toMatchObject({ kind: "ok" });
387
+ });
388
+ });
389
+
390
+ describe("a deliverable flow leaves the pair for /reports (#8848 D7, AC-4)", () => {
391
+ function deliverableConfig(): FlowConfig {
392
+ // As served: a gated block's target is projected out (#8848 D2).
393
+ const served = { ...config(), slug: "q3", deliverable: { kind: "page", gated: true, title: en("Q3 report") } };
394
+ // eslint-disable-next-line no-restricted-syntax -- a served gated block omits its target, which FlowConfig cannot express
395
+ return served as unknown as FlowConfig;
396
+ }
397
+
398
+ beforeEach(() => {
399
+ mockRedirect.mockClear();
400
+ mockGet.mockResolvedValue({ ok: true, body: deliverableConfig() });
401
+ });
402
+
403
+ it("renderMagnetLanding redirects to /reports/<slug>", async () => {
404
+ await expect(renderMagnetLanding({ slug: "q3", path: "/flows/q3", locale: "en" })).rejects.toThrow(
405
+ "NEXT_REDIRECT /reports/q3",
406
+ );
407
+ await expect(renderMagnetLanding({ slug: "q3", path: "/flows/q3", locale: "fr" })).rejects.toThrow(
408
+ "NEXT_REDIRECT /fr/reports/q3",
409
+ );
410
+ });
411
+
412
+ it("renderMagnetStart redirects to /reports/<slug>/start", async () => {
413
+ await expect(renderMagnetStart({ slug: "q3", locale: "en", backHref: "/flows/q3" })).rejects.toThrow(
414
+ "NEXT_REDIRECT /reports/q3/start",
415
+ );
416
+ });
417
+
418
+ it("renderMagnetStart with asDeliverable renders the walk", async () => {
419
+ render(await renderMagnetStart({ slug: "q3", locale: "en", backHref: "/reports/q3", asDeliverable: true }));
420
+ expect(mockRedirect).not.toHaveBeenCalled();
421
+ expect(screen.getByTestId("nav-back-link").getAttribute("href")).toBe("/reports/q3");
422
+ expect(screen.getByTestId("stepper")).toBeTruthy();
423
+ });
424
+
425
+ it("a config with no block renders as today", async () => {
426
+ mockGet.mockResolvedValue({ ok: true, body: config() });
427
+ render(await renderMagnetStart({ slug: "growth", locale: "en", backHref: "/diagnostic" }));
428
+ expect(mockRedirect).not.toHaveBeenCalled();
429
+ });
430
+ });
@@ -76,7 +76,14 @@ function fixtureConfig(): FlowConfig {
76
76
  } as FlowConfig;
77
77
  }
78
78
 
79
+ // #8840 D8 — both routes render through `magnet-page.tsx`, which shares one
80
+ // slug's read for 15 seconds per instance; each test starts an hour later so
81
+ // none sees another's read.
82
+ let clock = 1_800_000_000_000;
83
+ jest.spyOn(Date, "now").mockImplementation(() => clock);
84
+
79
85
  beforeEach(() => {
86
+ clock += 60 * 60 * 1000;
80
87
  mockGet.mockReset();
81
88
  mockGet.mockResolvedValue({ ok: true, body: fixtureConfig() });
82
89
  });
@@ -95,16 +102,21 @@ describe("the flow route", () => {
95
102
  expect(metadata.robots).toEqual({ index: false, follow: true });
96
103
  });
97
104
 
105
+ // #8840 D8 — the route mounts the standard renderer, which mounts
106
+ // `flowRouteMetadata`; neither writes the robots value itself.
98
107
  it("gets the value from flowRouteMetadata rather than writing it", () => {
99
- const code = readFileSync(
100
- join(__dirname, "..", "start", "page.tsx"),
101
- "utf8",
102
- )
103
- .replace(/\/\*[\s\S]*?\*\//g, "")
104
- .replace(/\/\/.*$/gm, "");
105
- expect(code).toContain("flowRouteMetadata");
106
- expect(code).not.toMatch(/robots/i);
107
- expect(code).not.toMatch(/noindex/i);
108
+ const strip = (file: string) =>
109
+ readFileSync(join(__dirname, "..", file), "utf8")
110
+ .replace(/\/\*[\s\S]*?\*\//g, "")
111
+ .replace(/\/\/.*$/gm, "");
112
+ const route = strip(join("start", "page.tsx"));
113
+ const renderer = strip("magnet-page.tsx");
114
+ expect(route).toContain("renderMagnetStartMetadata");
115
+ expect(renderer).toContain("flowRouteMetadata");
116
+ for (const code of [route, renderer]) {
117
+ expect(code).not.toMatch(/robots/i);
118
+ expect(code).not.toMatch(/noindex/i);
119
+ }
108
120
  });
109
121
  });
110
122