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
@@ -7,7 +7,7 @@
7
7
  * and the absent-header fail-safe — and the same for `isAccountPath()` (#7430).
8
8
  */
9
9
 
10
- import { AUTH_SEGMENTS, isAccountPath, isAuthPath } from "../auth-routes";
10
+ import { AUTH_SEGMENTS, isAccountPath, isAuthPath, isBookingPath } from "../auth-routes";
11
11
 
12
12
  const LOCALES = ["en", "fr", "de"];
13
13
 
@@ -111,3 +111,29 @@ describe("isAccountPath (#7430, spec 7423 D4)", () => {
111
111
  expect(isAccountPath(null, LOCALES)).toBe(false);
112
112
  });
113
113
  });
114
+
115
+ // #8843 D10 — the booking pages render without the marketing chrome.
116
+ describe("isBookingPath (#8843)", () => {
117
+ it("matches /meet, a meeting page and /private, bare or locale-prefixed", () => {
118
+ for (const path of ["/meet", "/meet/", "/meet/intro", "/en/meet/intro", "/fr/meet", "/private", "/fr/private"]) {
119
+ expect(isBookingPath(path, LOCALES)).toBe(true);
120
+ }
121
+ });
122
+
123
+ it("does not match the confirmation page, which keeps the marketing chrome", () => {
124
+ expect(isBookingPath("/meet/confirmed", LOCALES)).toBe(false);
125
+ expect(isBookingPath("/fr/meet/confirmed", LOCALES)).toBe(false);
126
+ });
127
+
128
+ it("does not match a near miss or a marketing path", () => {
129
+ expect(isBookingPath("/meeting", LOCALES)).toBe(false);
130
+ expect(isBookingPath("/privacy", LOCALES)).toBe(false);
131
+ expect(isBookingPath("/blog/meet", LOCALES)).toBe(false);
132
+ });
133
+
134
+ it("fails to NOT-a-booking-path for an empty or absent pathname", () => {
135
+ expect(isBookingPath("", LOCALES)).toBe(false);
136
+ expect(isBookingPath(undefined, LOCALES)).toBe(false);
137
+ expect(isBookingPath(null, LOCALES)).toBe(false);
138
+ });
139
+ });
@@ -69,8 +69,9 @@ describe("deckCandidates (#8687 D3, AC-2)", () => {
69
69
  ]);
70
70
  });
71
71
 
72
- it("/careers (gateable, no DECK_ROUTES entry) falls through to [slug]", () => {
72
+ it("/careers (sections mode since #8849) resolves the route row, then the [slug] fallback", () => {
73
73
  expect(deckCandidates("/careers", LOCALES)).toEqual([
74
+ { type: "CareersList", slug: "careers" },
74
75
  { type: "LandingPage", slug: "careers" },
75
76
  { type: "BlogList", slug: "careers" },
76
77
  ]);
@@ -0,0 +1,265 @@
1
+ /**
2
+ * lib/in-page-magnet.ts — the in-page form shape (#8847 D6, D15; AC-2).
3
+ *
4
+ * Pure mapping and loader wrapping; the two hub reads are mocked at
5
+ * `@working-theory/api-client`, and the Server Action modules are stubbed so
6
+ * the bound action is asserted by identity.
7
+ */
8
+ import { LeadCaptureForm } from "@working-theory/ui";
9
+ import {
10
+ contactFormFlowConfig,
11
+ newsletterFlowConfig,
12
+ type FlowConfig,
13
+ } from "@working-theory/validation";
14
+
15
+ jest.mock("@working-theory/api-client", () => ({
16
+ flows: { get: jest.fn() },
17
+ leads: { captureStatus: jest.fn() },
18
+ }));
19
+
20
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
21
+ jest.mock("../../app/[locale]/contact/contact.actions", () => ({
22
+ captureLeadAction: jest.fn(),
23
+ }));
24
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: a "use server" boundary the module only binds
25
+ jest.mock("../newsletter.actions", () => ({
26
+ subscribeNewsletterAction: jest.fn(),
27
+ }));
28
+
29
+ import { flows } from "@working-theory/api-client";
30
+
31
+ import { captureLeadAction } from "../../app/[locale]/contact/contact.actions";
32
+ import {
33
+ fillEndingName,
34
+ framedSources,
35
+ inPageView,
36
+ readServedMagnet,
37
+ renderInPageMagnet,
38
+ type InPageView,
39
+ } from "../in-page-magnet";
40
+
41
+ const mockFlowsGet = flows.get as jest.Mock;
42
+
43
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
44
+
45
+ /** A request-a-quote shaped contact magnet: name, email, need. */
46
+ const QUOTE: FlowConfig = {
47
+ slug: "quote",
48
+ defaultLocale: "en",
49
+ steps: [
50
+ {
51
+ key: "quote",
52
+ type: "field",
53
+ copy: { title: { en: "Request a quote" }, body: { en: "Tell us the job." }, cta: { en: "Ask" } },
54
+ fields: [
55
+ { name: "name", inputType: "text", required: true, mapsTo: "name", copy: { label: { en: "Name" } } },
56
+ { name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: { en: "Email" } } },
57
+ { name: "need", inputType: "textarea", required: false, copy: { label: { en: "What do you need?" } } },
58
+ ],
59
+ },
60
+ ],
61
+ };
62
+
63
+ const TEXT = {
64
+ unavailableHeading: "Unavailable",
65
+ unavailableBody: "Write to {email}.",
66
+ unavailableNoEmail: "No email.",
67
+ };
68
+
69
+ describe("fillEndingName (#8841 D1's rule)", () => {
70
+ it("drops {name} with its comma when there is no name, and fills it when there is", () => {
71
+ expect(fillEndingName("Thanks, {name}.", "")).toBe("Thanks.");
72
+ expect(fillEndingName("Thanks, {name}.", "Ada")).toBe("Thanks, Ada.");
73
+ });
74
+ });
75
+
76
+ describe("inPageView — the form comes from the caller (C1)", () => {
77
+ it("a quote-shaped config asked for as contact maps its fields, title and body", () => {
78
+ const view = inPageView(QUOTE, "en", "contact")!;
79
+ expect(view.title).toBe("Request a quote");
80
+ expect(view.body).toBe("Tell us the job.");
81
+ expect(view.stepKey).toBe("quote");
82
+ expect("fields" in view.form && view.form.fields.map((f) => f.name)).toEqual(["name", "email", "need"]);
83
+ });
84
+
85
+ it("the newsletter config asked for as contact is null — it has no name field", () => {
86
+ expect(inPageView(newsletterFlowConfig, "en", "contact")).toBeNull();
87
+ });
88
+
89
+ it("the newsletter shape served at another slug maps when the caller asks for newsletter", () => {
90
+ const updates = { ...newsletterFlowConfig, slug: "updates" };
91
+ const view = inPageView(updates, "en", "newsletter")!;
92
+ expect(view.form).toMatchObject({
93
+ kind: "newsletter",
94
+ emailField: "email",
95
+ label: "Email",
96
+ placeholder: "you@example.com",
97
+ consent: { field: "terms", purposes: ["newsletter"], help: "No spam. Unsubscribe any time." },
98
+ submitLabel: "Subscribe",
99
+ });
100
+ expect(view.ending).toEqual({
101
+ kind: "screen",
102
+ headline: "Check your inbox",
103
+ body: "Your welcome email is on its way.",
104
+ });
105
+ });
106
+
107
+ it("a newsletter view is null with any other field to fill in, or a second step", () => {
108
+ const extra = JSON.parse(JSON.stringify(newsletterFlowConfig)) as FlowConfig;
109
+ (extra.steps[0] as FieldStep).fields.push({
110
+ name: "company",
111
+ inputType: "text",
112
+ copy: { label: { en: "Company" } },
113
+ });
114
+ expect(inPageView(extra, "en", "newsletter")).toBeNull();
115
+ const twoSteps = { ...newsletterFlowConfig, steps: [...newsletterFlowConfig.steps, ...QUOTE.steps] };
116
+ expect(inPageView(twoSteps, "en", "newsletter")).toBeNull();
117
+ });
118
+
119
+ it("the Contact form's ending is its config's; a config with none ends on the caller's line", () => {
120
+ expect(inPageView(contactFormFlowConfig, "en", "contact")!.ending).toEqual({
121
+ kind: "screen",
122
+ headline: "Message sent.",
123
+ body: "Thanks for getting in touch. I reply within one business day.",
124
+ });
125
+ expect(inPageView(QUOTE, "en", "contact", "Thank you!")!.ending).toEqual({
126
+ kind: "screen",
127
+ headline: "Thank you!",
128
+ });
129
+ });
130
+
131
+ it("an off-site redirect or button is dropped, never rendered (security review)", () => {
132
+ const offSite = {
133
+ ...QUOTE,
134
+ ending: { default: { kind: "redirect" as const, href: "https://evil.example/x" } },
135
+ } as FlowConfig;
136
+ expect(inPageView(offSite, "en", "contact", "Thanks")!.ending).toEqual({ kind: "screen", headline: "Thanks" });
137
+ const buttons = {
138
+ ...QUOTE,
139
+ ending: {
140
+ default: {
141
+ kind: "screen" as const,
142
+ headline: { en: "Done" },
143
+ ctas: [
144
+ { label: { en: "Evil" }, href: "//evil.example" },
145
+ { label: { en: "Home" }, href: "/" },
146
+ ],
147
+ },
148
+ },
149
+ } as FlowConfig;
150
+ expect(inPageView(buttons, "en", "contact")!.ending).toEqual({
151
+ kind: "screen",
152
+ headline: "Done",
153
+ ctas: [{ label: "Home", href: "/" }],
154
+ });
155
+ });
156
+
157
+ it("a redirect ending and a screen's buttons are localized", () => {
158
+ const redirect = { ...QUOTE, ending: { default: { kind: "redirect" as const, href: "/thanks" } } };
159
+ expect(inPageView(redirect, "fr", "contact")!.ending).toEqual({ kind: "redirect", href: "/fr/thanks" });
160
+ });
161
+ });
162
+
163
+ describe("framedSources (D6)", () => {
164
+ const source = { frontmatter: { type: "ContactPage", slug: "contact", title: "Old title" }, body: "" };
165
+ const view: Pick<InPageView, "title" | "body"> = { title: "Say hello", body: "Two days, tops." };
166
+
167
+ it("with no contact-form section both loaders' output is unchanged", async () => {
168
+ const sections = [{ id: "faq", type: "faq", data: { items: [] } }];
169
+ const framed = framedSources({
170
+ getMdxSource: jest.fn().mockResolvedValue(source),
171
+ getPageSections: jest.fn().mockResolvedValue(sections),
172
+ view,
173
+ });
174
+ expect(await framed.getMdxSource("ContactPage", "contact", "en")).toBe(source);
175
+ expect(await framed.getPageSections(source.frontmatter)).toBe(sections);
176
+ });
177
+
178
+ it("a leading contact-form section takes the view's title and body, and so does the frontmatter title", async () => {
179
+ const sections = [
180
+ { id: "contact", type: "contact-form", data: { variant: "column", headline: "Old", description: "Old line", fields: [] } },
181
+ ];
182
+ const framed = framedSources({
183
+ getMdxSource: jest.fn().mockResolvedValue(source),
184
+ getPageSections: jest.fn().mockResolvedValue(sections),
185
+ view,
186
+ });
187
+ expect((await framed.getMdxSource("ContactPage", "contact", "en"))!.frontmatter.title).toBe("Say hello");
188
+ expect((await framed.getPageSections(source.frontmatter))[0]!.data).toMatchObject({
189
+ headline: "Say hello",
190
+ description: "Two days, tops.",
191
+ });
192
+ });
193
+
194
+ it("a view with no body deletes the section's description", async () => {
195
+ const sections = [{ id: "c", type: "contact-form", data: { description: "Old line", fields: [] } }];
196
+ const framed = framedSources({
197
+ getMdxSource: jest.fn().mockResolvedValue(source),
198
+ getPageSections: jest.fn().mockResolvedValue(sections),
199
+ view: { title: "Say hello" },
200
+ });
201
+ expect((await framed.getPageSections(source.frontmatter))[0]!.data).not.toHaveProperty("description");
202
+ });
203
+ });
204
+
205
+ describe("renderInPageMagnet (D15)", () => {
206
+ it("a quote magnet asked for as contact binds captureLeadAction, stamped with its flow source", () => {
207
+ const view = inPageView(QUOTE, "en", "contact")!;
208
+ const element = renderInPageMagnet({
209
+ slug: "quote",
210
+ locale: "en",
211
+ view,
212
+ text: { ...TEXT } as never,
213
+ accepting: true,
214
+ });
215
+ expect(element.type).toBe(LeadCaptureForm);
216
+ expect(element.props).toMatchObject({
217
+ action: captureLeadAction,
218
+ // captureLeadAction keeps only CONTACT_LEAD_SOURCES, so this lands as contact-form.
219
+ source: "flow:quote",
220
+ look: "column",
221
+ ending: view.ending,
222
+ });
223
+ });
224
+
225
+ it("capture off is ContactFallback with the site's email", () => {
226
+ const element = renderInPageMagnet({
227
+ slug: "quote",
228
+ locale: "en",
229
+ view: inPageView(QUOTE, "en", "contact")!,
230
+ text: { ...TEXT } as never,
231
+ accepting: false,
232
+ fallbackEmail: "hi@x.co",
233
+ });
234
+ expect(element.props).toMatchObject({ email: "hi@x.co" });
235
+ });
236
+ });
237
+
238
+ describe("a config with no ending (review M1)", () => {
239
+ it("has no ending unless the caller gives a fallback line", () => {
240
+ expect(inPageView(QUOTE, "en", "contact")).not.toHaveProperty("ending");
241
+ });
242
+ });
243
+
244
+ describe("readServedMagnet", () => {
245
+ it("a hub that never answers is null after the deadline, not a hung page (review H2)", async () => {
246
+ jest.useFakeTimers();
247
+ try {
248
+ mockFlowsGet.mockReturnValueOnce(new Promise(() => undefined));
249
+ const read = readServedMagnet("stalled");
250
+ jest.advanceTimersByTime(2_000);
251
+ expect(await read).toBeNull();
252
+ } finally {
253
+ jest.useRealTimers();
254
+ }
255
+ });
256
+
257
+ it("is the served config, and null for a non-2xx or a throw", async () => {
258
+ mockFlowsGet.mockResolvedValueOnce({ ok: true, status: 200, body: QUOTE });
259
+ expect(await readServedMagnet("quote")).toBe(QUOTE);
260
+ mockFlowsGet.mockResolvedValueOnce({ ok: false, status: 404, body: {} });
261
+ expect(await readServedMagnet("absent")).toBeNull();
262
+ mockFlowsGet.mockRejectedValueOnce(new Error("hub down"));
263
+ expect(await readServedMagnet("down")).toBeNull();
264
+ });
265
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * #8840 D10, AC-4 — the magnet walk predicate the layout drops the chrome on.
3
+ */
4
+ import { isMagnetWalkPath } from "../magnet-routes";
5
+
6
+ const LOCALES = ["en", "fr"];
7
+
8
+ describe("isMagnetWalkPath (#8840 D10)", () => {
9
+ it.each([
10
+ "/diagnostic/start",
11
+ "/fr/diagnostic/start",
12
+ "/flows/a/start",
13
+ "/careers/apply",
14
+ "/careers/writer/apply",
15
+ "/fr/careers/writer/apply",
16
+ "/diagnostic/start?step=2",
17
+ ])("is a walk: %s", (path) => {
18
+ expect(isMagnetWalkPath(path, LOCALES)).toBe(true);
19
+ });
20
+
21
+ it.each([
22
+ "/diagnostic",
23
+ "/careers",
24
+ "/careers/writer",
25
+ "/start-here",
26
+ "/fr",
27
+ "/",
28
+ "",
29
+ "/apply",
30
+ "/careers/a/b/apply",
31
+ ])("is not a walk: %j", (path) => {
32
+ expect(isMagnetWalkPath(path, LOCALES)).toBe(false);
33
+ });
34
+
35
+ it("answers false for an absent pathname", () => {
36
+ expect(isMagnetWalkPath(undefined, LOCALES)).toBe(false);
37
+ expect(isMagnetWalkPath(null, LOCALES)).toBe(false);
38
+ });
39
+
40
+ it("treats a prefix that is not an enabled locale as a first segment", () => {
41
+ expect(isMagnetWalkPath("/de/careers/apply", LOCALES)).toBe(false);
42
+ expect(isMagnetWalkPath("/de/start", LOCALES)).toBe(true);
43
+ });
44
+ });
45
+
46
+ describe("isMagnetWalkPath — the deliverable pages (#8848 D9, AC-4)", () => {
47
+ it.each(["/en/reports/q3", "/reports/q3", "/reports/q3/start", "/fr/reports/q3/start"])(
48
+ "is chrome-free: %s",
49
+ (path) => {
50
+ expect(isMagnetWalkPath(path, ["en", "fr"])).toBe(true);
51
+ },
52
+ );
53
+
54
+ it.each(["/reports", "/en/reports", "/en/reports/"])("keeps the index's chrome: %s", (path) => {
55
+ expect(isMagnetWalkPath(path, ["en", "fr"])).toBe(false);
56
+ });
57
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * subscribeNewsletterAction (#6730 D9) — the Server Action twin of the
3
+ * `ailkSubscribeNewsletter` MCP tool.
4
+ */
5
+
6
+ import { subscribeNewsletterAction } from "../newsletter.actions.js";
7
+
8
+ // `visitorForwardHeaders` stays real (#7394) so AC-9 compares the action's
9
+ // `init` with the helper's own output.
10
+ // `flows.get` answers 404 by default — a Free build with no flow route — so
11
+ // the #6730 cases below take `leads.subscribe`; the #8847 cases serve the
12
+ // newsletter magnet.
13
+ jest.mock("@working-theory/api-client", () => ({
14
+ flows: {
15
+ get: jest.fn().mockResolvedValue({ ok: false, status: 404, body: {} }),
16
+ submitStep: jest.fn(),
17
+ },
18
+ leads: { subscribe: jest.fn() },
19
+ visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
20
+ .visitorForwardHeaders,
21
+ }));
22
+
23
+ // next/headers' `headers()` is request-scoped in Next; outside it, it is
24
+ // mocked here the way flow.actions.test.ts does (#7394 AC-9).
25
+ jest.mock("next/headers", () => ({
26
+ __esModule: true,
27
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
28
+ }));
29
+
30
+ jest.mock("next-intl/server", () => ({
31
+ __esModule: true,
32
+ getLocale: jest.fn().mockResolvedValue("en"),
33
+ }));
34
+
35
+ import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
36
+ import { newsletterFlowConfig } from "@working-theory/validation";
37
+ import { headers } from "next/headers";
38
+ const mockSubscribe = leads.subscribe as jest.Mock;
39
+ const mockFlowsGet = flows.get as jest.Mock;
40
+ const mockSubmitStep = flows.submitStep as jest.Mock;
41
+
42
+ // The action's magnet read is shared for a few seconds per module instance;
43
+ // each test starts an hour later so none sees another's read.
44
+ let clock = 1_900_000_000_000;
45
+ jest.spyOn(Date, "now").mockImplementation(() => clock);
46
+ beforeEach(() => {
47
+ clock += 60 * 60 * 1000;
48
+ });
49
+ const mockHeaders = headers as jest.Mock;
50
+
51
+ function formWith(email: string): FormData {
52
+ const fd = new FormData();
53
+ fd.append("email", email);
54
+ fd.append("consent", "true");
55
+ return fd;
56
+ }
57
+
58
+ afterEach(() => {
59
+ jest.clearAllMocks();
60
+ mockHeaders.mockResolvedValue({ get: () => null });
61
+ });
62
+
63
+ describe("subscribeNewsletterAction", () => {
64
+ it("calls leads.subscribe with source newsletter and the address", async () => {
65
+ mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
66
+
67
+ const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
68
+
69
+ expect(result).toEqual({ kind: "success" });
70
+ expect(mockSubscribe).toHaveBeenCalledWith(
71
+ expect.objectContaining({ source: "newsletter", email: "ada@example.com" }),
72
+ );
73
+ });
74
+
75
+ it("an invalid address → a field error, no API call", async () => {
76
+ const result = await subscribeNewsletterAction(null, formWith("not-an-email"));
77
+
78
+ expect(result).toMatchObject({ kind: "error", fieldErrors: { email: expect.any(String) } });
79
+ expect(mockSubscribe).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("503 lead_capture_not_configured → unavailable", async () => {
83
+ mockSubscribe.mockResolvedValueOnce({
84
+ ok: false,
85
+ status: 503,
86
+ body: { error: "lead_capture_not_configured" },
87
+ });
88
+
89
+ const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
90
+
91
+ expect(result).toEqual({ kind: "unavailable" });
92
+ });
93
+
94
+ it("any other failure → the generic error, never success", async () => {
95
+ mockSubscribe.mockResolvedValueOnce({ ok: false, status: 503, body: { error: "persist_failed" } });
96
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
97
+ kind: "error",
98
+ });
99
+
100
+ mockSubscribe.mockRejectedValueOnce(new Error("ECONNREFUSED"));
101
+ jest.spyOn(console, "error").mockImplementationOnce(() => undefined);
102
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
103
+ kind: "error",
104
+ });
105
+ });
106
+
107
+ it("a filled honeypot → success, and nothing is sent", async () => {
108
+ const fd = formWith("ada@example.com");
109
+ fd.append("ailk_hp", "spam");
110
+
111
+ expect(await subscribeNewsletterAction(null, fd)).toEqual({ kind: "success" });
112
+ expect(mockSubscribe).not.toHaveBeenCalled();
113
+ });
114
+ });
115
+
116
+ // #7394 AC-9 — a spoke forwards its visitor to the hub's leads rate limit.
117
+ describe("subscribeNewsletterAction — visitor forwarding (#7394)", () => {
118
+ const FORWARD_KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
119
+ const ENV_KEYS = ["AILK_VISITOR_FORWARD_KEY", "AILK_API_URL"] as const;
120
+ const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
121
+
122
+ beforeEach(() => {
123
+ process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
124
+ process.env.AILK_API_URL = "https://hub.example";
125
+ });
126
+
127
+ afterAll(() => {
128
+ for (const [name, value] of priorEnv) {
129
+ if (value === undefined) delete process.env[name];
130
+ else process.env[name] = value;
131
+ }
132
+ });
133
+
134
+ it("passes the helper's two headers as init", async () => {
135
+ const incoming = {
136
+ get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.5" : null),
137
+ };
138
+ mockHeaders.mockResolvedValue(incoming);
139
+ mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
140
+
141
+ const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
142
+
143
+ expect(result).toEqual({ kind: "success" });
144
+ const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
145
+ expect(expected).toEqual({
146
+ "x-ailk-visitor-ip": "203.0.113.5",
147
+ "x-ailk-visitor-forward-key": FORWARD_KEY,
148
+ });
149
+ expect(mockSubscribe).toHaveBeenCalledWith(expect.any(Object), { headers: expected });
150
+ });
151
+
152
+ it("calls leads.subscribe with one argument when headers() throws, and still succeeds", async () => {
153
+ mockHeaders.mockRejectedValue(new Error("outside a request scope"));
154
+ mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
155
+
156
+ const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
157
+
158
+ expect(result).toEqual({ kind: "success" });
159
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
160
+ expect(mockSubscribe.mock.calls[0]).toHaveLength(1);
161
+ });
162
+ });
163
+
164
+ describe("subscribeNewsletterAction — through the newsletter magnet (#8847 D11, AC-6)", () => {
165
+ function servedMagnet(): void {
166
+ const { completionEmail: _omitted, ...body } = newsletterFlowConfig;
167
+ mockFlowsGet.mockResolvedValue({ ok: true, status: 200, body });
168
+ }
169
+ afterEach(() => {
170
+ mockFlowsGet.mockResolvedValue({ ok: false, status: 404, body: {} });
171
+ });
172
+
173
+ it("a ticked post submits the magnet's step once, with the consent purpose, and never leads.subscribe", async () => {
174
+ servedMagnet();
175
+ mockSubmitStep.mockResolvedValueOnce({
176
+ ok: true,
177
+ status: 200,
178
+ body: { sessionId: "s1", nextStepKey: null, completed: true },
179
+ });
180
+
181
+ const result = await subscribeNewsletterAction(null, formWith("a@b.co"));
182
+
183
+ expect(result).toEqual({ kind: "success" });
184
+ expect(mockSubmitStep).toHaveBeenCalledTimes(1);
185
+ expect(mockSubmitStep).toHaveBeenCalledWith("newsletter", {
186
+ stepKey: "subscribe",
187
+ values: { email: "a@b.co", "terms.newsletter": true },
188
+ locale: "en",
189
+ });
190
+ expect(mockSubscribe).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("a post without consent → fieldErrors.consent, and no API call", async () => {
194
+ servedMagnet();
195
+ const fd = new FormData();
196
+ fd.append("email", "a@b.co");
197
+
198
+ const result = await subscribeNewsletterAction(null, fd);
199
+
200
+ expect(result).toEqual({
201
+ kind: "error",
202
+ fieldErrors: { consent: "Please accept the terms to subscribe." },
203
+ });
204
+ expect(mockSubmitStep).not.toHaveBeenCalled();
205
+ expect(mockSubscribe).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it.each([
209
+ ["a 503 lead_capture_not_configured", { ok: false, status: 503, body: { error: "lead_capture_not_configured" } }],
210
+ ["an unreachable hub (status 0)", { ok: false, status: 0, body: null }],
211
+ ["a 502", { ok: false, status: 502, body: null }],
212
+ ])("%s → unavailable", async (_label, answer) => {
213
+ servedMagnet();
214
+ mockSubmitStep.mockResolvedValueOnce(answer);
215
+
216
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toEqual({ kind: "unavailable" });
217
+ });
218
+
219
+ it("any other refusal → the generic error", async () => {
220
+ servedMagnet();
221
+ mockSubmitStep.mockResolvedValueOnce({ ok: false, status: 400, body: { error: "invalid" } });
222
+
223
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({ kind: "error" });
224
+ });
225
+
226
+ it("a wizard-built newsletter with no consent field submits without one (review H1)", async () => {
227
+ const { completionEmail: _omitted, ...body } = newsletterFlowConfig;
228
+ const step = body.steps[0] as { fields: Array<{ name: string }> };
229
+ mockFlowsGet.mockResolvedValue({
230
+ ok: true,
231
+ status: 200,
232
+ body: { ...body, steps: [{ ...step, fields: step.fields.filter((f) => f.name === "email") }] },
233
+ });
234
+ mockSubmitStep.mockResolvedValueOnce({
235
+ ok: true,
236
+ status: 200,
237
+ body: { sessionId: "s1", nextStepKey: null, completed: true },
238
+ });
239
+ const fd = new FormData();
240
+ fd.append("email", "a@b.co");
241
+
242
+ expect(await subscribeNewsletterAction(null, fd)).toEqual({ kind: "success" });
243
+ expect(mockSubmitStep).toHaveBeenCalledWith("newsletter", {
244
+ stepKey: "subscribe",
245
+ values: { email: "a@b.co" },
246
+ locale: "en",
247
+ });
248
+ });
249
+
250
+ it("with no magnet, a post without consent is refused and nothing is sent", async () => {
251
+ const fd = new FormData();
252
+ fd.append("email", "a@b.co");
253
+
254
+ expect(await subscribeNewsletterAction(null, fd)).toMatchObject({
255
+ kind: "error",
256
+ fieldErrors: { consent: expect.any(String) },
257
+ });
258
+ expect(mockSubscribe).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it("forwards the visitor's address to the step route, and refuses a visitor's 31st post in ten minutes (security review)", async () => {
262
+ servedMagnet();
263
+ mockHeaders.mockResolvedValue({
264
+ get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.9" : null),
265
+ });
266
+ mockSubmitStep.mockResolvedValue({
267
+ ok: true,
268
+ status: 200,
269
+ body: { sessionId: "s1", nextStepKey: null, completed: true },
270
+ });
271
+
272
+ for (let i = 0; i < 30; i += 1) {
273
+ expect(await subscribeNewsletterAction(null, formWith(`a${i}@b.co`))).toEqual({ kind: "success" });
274
+ }
275
+ expect(mockSubmitStep.mock.calls[0]![2]).toEqual({
276
+ headers: expect.objectContaining({ "x-forwarded-for": "203.0.113.9" }),
277
+ });
278
+
279
+ expect(await subscribeNewsletterAction(null, formWith("late@b.co"))).toMatchObject({
280
+ kind: "error",
281
+ message: expect.any(String),
282
+ });
283
+ expect(mockSubmitStep).toHaveBeenCalledTimes(30);
284
+ });
285
+
286
+ it("with flows.get 404 (a Free build) the same post calls leads.subscribe and not submitStep", async () => {
287
+ mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
288
+
289
+ expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toEqual({ kind: "success" });
290
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
291
+ expect(mockSubmitStep).not.toHaveBeenCalled();
292
+ });
293
+ });