create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -33,15 +33,24 @@ import { readFileSync } from "node:fs";
33
33
  import { join } from "node:path";
34
34
 
35
35
  import { prisma } from "../../lib/prisma.js";
36
+ import { personContactPatchSchema } from "@working-theory/validation";
37
+
36
38
  import {
39
+ contactConfirmedFromRow,
40
+ ensureMemberPerson,
37
41
  findOrCreatePerson,
38
42
  isStorablePersonEmail,
43
+ normalizeCapturedContact,
39
44
  normalizePersonEmail,
45
+ normalizeTelephone,
46
+ personContactFromRow,
40
47
  recordCheckoutPerson,
41
48
  resolveCallerBillingCustomerId,
42
49
  resolveMemberPersonId,
50
+ setOwnPersonContact,
43
51
  setPersonPlanOffering,
44
52
  stampPersonUser,
53
+ updatePersonContact,
45
54
  } from "../person";
46
55
 
47
56
  type Row = Record<string, unknown>;
@@ -77,6 +86,20 @@ function seedPerson(row: Row): Row {
77
86
  billingProvider: null,
78
87
  billingCustomerId: null,
79
88
  planOffering: null,
89
+ // #8435 — the contact columns, blank the way Postgres reads them back.
90
+ givenName: null,
91
+ familyName: null,
92
+ telephone: null,
93
+ addressLine1: null,
94
+ addressLine2: null,
95
+ addressCity: null,
96
+ addressRegion: null,
97
+ addressPostalCode: null,
98
+ addressCountry: null,
99
+ companyName: null,
100
+ jobTitle: null,
101
+ contactConfirmedAt: null,
102
+ contactConfirmedBy: null,
80
103
  createdAt: new Date(Date.UTC(2026, 0, 1 + people().length)),
81
104
  updatedAt: new Date(),
82
105
  ...row,
@@ -531,6 +554,375 @@ describe("resolveCallerBillingCustomerId (#7961, spec 7894 D13, AC-8)", () => {
531
554
  });
532
555
  });
533
556
 
557
+ const ADDRESS = {
558
+ addressLine1: "1 Main St",
559
+ addressLine2: null,
560
+ city: "Austin",
561
+ region: "TX",
562
+ postalCode: "78701",
563
+ country: "US",
564
+ };
565
+ const ADDRESS_COLUMNS = {
566
+ addressLine1: "1 Main St",
567
+ addressLine2: null,
568
+ addressCity: "Austin",
569
+ addressRegion: "TX",
570
+ addressPostalCode: "78701",
571
+ addressCountry: "US",
572
+ };
573
+
574
+ describe("findOrCreatePerson fills blank contact fields (#8435 D4, AC-2)", () => {
575
+ const contactCalls = () =>
576
+ store.__calls.filter((c) => c.model === "person" && c.op === "updateMany");
577
+
578
+ it("a new email with a contact creates one Person carrying it, named from given + family", async () => {
579
+ const result = await findOrCreatePerson(WS_A1, {
580
+ email: "new@x.io",
581
+ contact: { givenName: "Ada", familyName: "Lovelace", telephone: "+442071234567" },
582
+ });
583
+ expect(result.ok && result.value.created).toBe(true);
584
+ expect(people()).toHaveLength(1);
585
+ expect(people()[0]).toEqual(
586
+ expect.objectContaining({
587
+ givenName: "Ada",
588
+ familyName: "Lovelace",
589
+ telephone: "+442071234567",
590
+ name: "Ada Lovelace",
591
+ }),
592
+ );
593
+ });
594
+
595
+ it("given name alone names the Person; a supplied name wins over given + family", async () => {
596
+ await findOrCreatePerson(WS_A1, { email: "a@x.io", contact: { givenName: "Ada" } });
597
+ await findOrCreatePerson(WS_A1, {
598
+ email: "b@x.io",
599
+ name: "Countess",
600
+ contact: { givenName: "Ada", familyName: "Lovelace" },
601
+ });
602
+ expect(people().map((p) => p["name"])).toEqual(["Ada", "Countess"]);
603
+ });
604
+
605
+ it("a matched Person's stored value is never overwritten; its blank fields are filled", async () => {
606
+ seedPerson({ email: "m@x.io", telephone: "+15550000001" });
607
+ await findOrCreatePerson(WS_A1, {
608
+ email: "m@x.io",
609
+ contact: { telephone: "+15550000002", companyName: "Acme" },
610
+ });
611
+ expect(people()[0]).toEqual(
612
+ expect.objectContaining({ telephone: "+15550000001", companyName: "Acme" }),
613
+ );
614
+ });
615
+
616
+ it("a stored address is left whole; a Person with none gains all six parts", async () => {
617
+ seedPerson({
618
+ email: "has@x.io",
619
+ addressLine1: "9 Old Rd",
620
+ addressCity: "Leeds",
621
+ addressRegion: "WYK",
622
+ addressPostalCode: "LS1",
623
+ addressCountry: "GB",
624
+ });
625
+ seedPerson({ email: "none@x.io" });
626
+ await findOrCreatePerson(WS_A1, { email: "has@x.io", contact: { address: ADDRESS } });
627
+ await findOrCreatePerson(WS_A1, { email: "none@x.io", contact: { address: ADDRESS } });
628
+ expect(people()[0]).toEqual(
629
+ expect.objectContaining({ addressLine1: "9 Old Rd", addressCity: "Leeds", addressCountry: "GB" }),
630
+ );
631
+ expect(people()[1]).toEqual(expect.objectContaining(ADDRESS_COLUMNS));
632
+ });
633
+
634
+ it("a name already on the Person is not replaced by given + family", async () => {
635
+ seedPerson({ email: "n@x.io", name: "A. L." });
636
+ await findOrCreatePerson(WS_A1, {
637
+ email: "n@x.io",
638
+ contact: { givenName: "Ada", familyName: "Lovelace" },
639
+ });
640
+ expect(people()[0]).toEqual(
641
+ expect.objectContaining({ name: "A. L.", givenName: "Ada", familyName: "Lovelace" }),
642
+ );
643
+ });
644
+
645
+ it("a nameless uncurated Person gains the joined name", async () => {
646
+ seedPerson({ email: "n@x.io" });
647
+ await findOrCreatePerson(WS_A1, {
648
+ email: "n@x.io",
649
+ contact: { givenName: "Ada", familyName: "Lovelace" },
650
+ });
651
+ expect(people()[0]!["name"]).toBe("Ada Lovelace");
652
+ });
653
+
654
+ it("a customer-id hit (step 2) writes nothing — contact included", async () => {
655
+ seedPerson({ email: "c@x.io", billingProvider: "stripe", billingCustomerId: "cus_1" });
656
+ await findOrCreatePerson(WS_A1, {
657
+ email: "other@x.io",
658
+ billingCustomerId: "cus_1",
659
+ contact: { telephone: "+15550000003" },
660
+ });
661
+ expect(contactCalls()).toHaveLength(0);
662
+ expect(people()[0]!["telephone"]).toBeNull();
663
+ });
664
+
665
+ it("a double whose matched row carries no contact keys triggers no contact write", async () => {
666
+ seedPerson({ email: "d@x.io" });
667
+ store.person["findFirst"]!.mockResolvedValueOnce({
668
+ id: people()[0]!["id"],
669
+ name: null,
670
+ userId: null,
671
+ billingCustomerId: null,
672
+ });
673
+ await findOrCreatePerson(WS_A1, { email: "d@x.io", contact: { telephone: "+15550000004" } });
674
+ expect(contactCalls()).toHaveLength(0);
675
+ });
676
+
677
+ describe("only an uncurated Person is filled (SEC-8435-A)", () => {
678
+ it("a Person linked to a user is not filled", async () => {
679
+ seedPerson({ email: "u@x.io", userId: "user_00000000-0000-7000-8000-0000000000e1" });
680
+ await findOrCreatePerson(WS_A1, { email: "u@x.io", contact: { telephone: "+15550000005" } });
681
+ expect(people()[0]!["telephone"]).toBeNull();
682
+ expect(contactCalls()).toHaveLength(0);
683
+ });
684
+
685
+ it("a Person a human saved is not filled — a field they cleared is not refilled", async () => {
686
+ seedPerson({ email: "h@x.io", contactConfirmedAt: new Date(), contactConfirmedBy: "client" });
687
+ await findOrCreatePerson(WS_A1, { email: "h@x.io", contact: { telephone: "+15550000006" } });
688
+ expect(people()[0]!["telephone"]).toBeNull();
689
+ expect(contactCalls()).toHaveLength(0);
690
+ });
691
+
692
+ it("an uncurated Person is filled", async () => {
693
+ seedPerson({ email: "free@x.io" });
694
+ await findOrCreatePerson(WS_A1, { email: "free@x.io", contact: { telephone: "+15550000007" } });
695
+ expect(people()[0]!["telephone"]).toBe("+15550000007");
696
+ });
697
+
698
+ it("a link that lands between the match read and the fill wins: the guarded write matches nothing", async () => {
699
+ seedPerson({ email: "race@x.io" });
700
+ const read = store.person["findFirst"]!.getMockImplementation()!;
701
+ store.person["findFirst"]!.mockImplementationOnce(async (args: unknown) => {
702
+ const row = await read(args);
703
+ people()[0]!["userId"] = "user_00000000-0000-7000-8000-0000000000e2";
704
+ return row;
705
+ });
706
+ await findOrCreatePerson(WS_A1, { email: "race@x.io", contact: { telephone: "+15550000008" } });
707
+ expect(people()[0]!["telephone"]).toBeNull();
708
+ });
709
+ });
710
+
711
+ it("a failing fill never changes the capture's outcome", async () => {
712
+ seedPerson({ email: "fail@x.io" });
713
+ store.person["updateMany"]!.mockRejectedValueOnce(new Error("down"));
714
+ const result = await findOrCreatePerson(WS_A1, {
715
+ email: "fail@x.io",
716
+ contact: { telephone: "+15550000009" },
717
+ });
718
+ expect(result.ok && result.value.created).toBe(false);
719
+ });
720
+ });
721
+
722
+ describe("the contact normalizers (#8435 D3, AC-2)", () => {
723
+ it.each([
724
+ ["+1 (415) 555-0123", "+14155550123"],
725
+ ["+44 20 7123 4567", "+442071234567"],
726
+ ["+1.415.555.0123", "+14155550123"],
727
+ ["415-555-0123", null],
728
+ ["+0 123", null],
729
+ ["+1234567890123456", null],
730
+ ["", null],
731
+ [4155550123, null],
732
+ [null, null],
733
+ ])("normalizeTelephone(%p) → %p", (raw, expected) => {
734
+ expect(normalizeTelephone(raw)).toBe(expected);
735
+ });
736
+
737
+ it("strips bidi, zero-width, separator and control characters from a given name", () => {
738
+ expect(normalizeCapturedContact({ givenName: "A‮d​a
\u0007" }).givenName).toBe("Ada");
739
+ });
740
+
741
+ it("cuts a 300-character company to 200 code points", () => {
742
+ const company = normalizeCapturedContact({ company: "c".repeat(300) }).companyName;
743
+ expect(Array.from(company ?? "")).toHaveLength(200);
744
+ });
745
+
746
+ it("stores a short region upper-cased, a full region as typed, and drops an address missing a part whole", () => {
747
+ const base = { addressLine1: "1 Main", city: "Albany", postalCode: "12207", country: "US" };
748
+ expect(normalizeCapturedContact({ address: { ...base, region: "ny" } }).address?.region).toBe("NY");
749
+ expect(normalizeCapturedContact({ address: { ...base, region: "New York" } }).address?.region).toBe(
750
+ "New York",
751
+ );
752
+ expect(normalizeCapturedContact({ address: { ...base, region: "NY", postalCode: undefined } }).address).toBeUndefined();
753
+ expect(normalizeCapturedContact({ address: { ...base, region: "NY", country: "us" } }).address).toBeUndefined();
754
+ });
755
+
756
+ it("drops a non-string value and an empty-after-cleaning one", () => {
757
+ expect(
758
+ normalizeCapturedContact({ givenName: 12, familyName: "​ ", company: {}, jobTitle: null, phone: "555" }),
759
+ ).toEqual({});
760
+ });
761
+
762
+ describe("the capture normalizer and the wire's edit schema agree on one table", () => {
763
+ // (field, raw) — within its width, so a capture would keep every character.
764
+ const TABLE: Array<[string, string]> = [
765
+ ["givenName", "Ada"],
766
+ ["givenName", " Ada "],
767
+ ["givenName", "A‮d​a
\u0007"],
768
+ ["givenName", "\u{1D56B}".repeat(100)],
769
+ ["givenName", "​ "],
770
+ ["familyName", "Lovelace"],
771
+ ["companyName", "c".repeat(200)],
772
+ ["jobTitle", "Founder"],
773
+ ["telephone", "+1 (415) 555-0123"],
774
+ ["telephone", "415-555-0123"],
775
+ ["telephone", "+1 415 555 0123"],
776
+ ];
777
+ const CAPTURE_KEY: Record<string, string> = {
778
+ givenName: "givenName",
779
+ familyName: "familyName",
780
+ companyName: "company",
781
+ jobTitle: "jobTitle",
782
+ telephone: "phone",
783
+ };
784
+
785
+ it.each(TABLE)("%s %j", (field, raw) => {
786
+ const captured = normalizeCapturedContact({ [CAPTURE_KEY[field]!]: raw }) as Record<string, unknown>;
787
+ const parsed = personContactPatchSchema.safeParse({ [field]: raw });
788
+ if (parsed.success) {
789
+ expect(captured[field]).toBe((parsed.data as Record<string, unknown>)[field]);
790
+ } else {
791
+ expect(captured[field]).toBeUndefined();
792
+ }
793
+ });
794
+
795
+ it("an address and its short region agree", () => {
796
+ const address = { addressLine1: "1 Main", city: "Albany", region: "ny", postalCode: "12207", country: "US" };
797
+ const captured = normalizeCapturedContact({ address }).address;
798
+ const parsed = personContactPatchSchema.parse({ address }).address;
799
+ expect(captured).toEqual(parsed);
800
+ });
801
+ });
802
+ });
803
+
804
+ describe("the two human edits of the contact (#8435 D9, D11)", () => {
805
+ const U = "user_00000000-0000-7000-8000-0000000000f1";
806
+ const users = () => (store.__db["user"] ??= []);
807
+
808
+ it("updatePersonContact writes only the keys present, stamps the operator and serves the saved contact", async () => {
809
+ const p = seedPerson({ email: "o@x.io", companyName: "Keep" });
810
+ const outcome = await updatePersonContact(WS_A1, p["id"] as string, {
811
+ telephone: "+14155550123",
812
+ address: ADDRESS,
813
+ });
814
+ expect(outcome.kind).toBe("saved");
815
+ expect(people()[0]).toEqual(
816
+ expect.objectContaining({
817
+ telephone: "+14155550123",
818
+ companyName: "Keep",
819
+ contactConfirmedBy: "operator",
820
+ ...ADDRESS_COLUMNS,
821
+ }),
822
+ );
823
+ expect(people()[0]!["contactConfirmedAt"]).toBeInstanceOf(Date);
824
+ if (outcome.kind !== "saved") return;
825
+ expect(outcome.value.confirmed?.by).toBe("operator");
826
+ expect(outcome.value.contact.address).toEqual(ADDRESS);
827
+ expect(outcome.value.contact.companyName).toBe("Keep");
828
+ });
829
+
830
+ it("an address of null clears all six parts", async () => {
831
+ const p = seedPerson({ email: "o@x.io", ...ADDRESS_COLUMNS });
832
+ await updatePersonContact(WS_A1, p["id"] as string, { address: null });
833
+ expect(people()[0]).toEqual(
834
+ expect.objectContaining({ addressLine1: null, addressCity: null, addressCountry: null }),
835
+ );
836
+ });
837
+
838
+ it("another workspace's Person, and an absent one, are not_found and unchanged", async () => {
839
+ const p = seedPerson({ workspaceId: WS_A2, email: "o@x.io" });
840
+ expect((await updatePersonContact(WS_A1, p["id"] as string, { jobTitle: "x" })).kind).toBe("not_found");
841
+ expect((await updatePersonContact(WS_A1, "person_nope", { jobTitle: "x" })).kind).toBe("not_found");
842
+ expect(people()[0]!["jobTitle"]).toBeNull();
843
+ });
844
+
845
+ it("a store error is internal_error, never a throw", async () => {
846
+ store.person["updateMany"]!.mockRejectedValueOnce(new Error("down"));
847
+ expect((await updatePersonContact(WS_A1, "person_x", { jobTitle: "x" })).kind).toBe("internal_error");
848
+ });
849
+
850
+ it("setOwnPersonContact replaces all six keys on the caller's own Person only", async () => {
851
+ const mine = seedPerson({ email: "me@x.io", userId: U, companyName: "Capture-filled", jobTitle: "Boss" });
852
+ const other = seedPerson({ email: "other@x.io", userId: "user_00000000-0000-7000-8000-0000000000f2" });
853
+ const outcome = await setOwnPersonContact(WS_A1, mine["id"] as string, U, {
854
+ givenName: "Me",
855
+ familyName: null,
856
+ telephone: "+14155550123",
857
+ address: null,
858
+ companyName: null,
859
+ jobTitle: null,
860
+ });
861
+ expect(outcome.kind).toBe("saved");
862
+ expect(people()[0]).toEqual(
863
+ expect.objectContaining({
864
+ givenName: "Me",
865
+ companyName: null,
866
+ jobTitle: null,
867
+ contactConfirmedBy: "client",
868
+ }),
869
+ );
870
+ expect(people()[1]).toBe(other);
871
+ expect(people()[1]!["givenName"]).toBeNull();
872
+ });
873
+
874
+ it("setOwnPersonContact is not_found for a Person linked to someone else, or of another workspace", async () => {
875
+ const linkedElsewhere = seedPerson({ email: "a@x.io", userId: "user_00000000-0000-7000-8000-0000000000f3" });
876
+ const otherWorkspace = seedPerson({ workspaceId: WS_A2, email: "me@x.io", userId: U });
877
+ const body = { givenName: "X", familyName: null, telephone: null, address: null, companyName: null, jobTitle: null };
878
+ expect((await setOwnPersonContact(WS_A1, linkedElsewhere["id"] as string, U, body)).kind).toBe("not_found");
879
+ expect((await setOwnPersonContact(WS_A1, otherWorkspace["id"] as string, U, body)).kind).toBe("not_found");
880
+ expect(people().map((p) => p["givenName"])).toEqual([null, null]);
881
+ });
882
+
883
+ it("ensureMemberPerson creates the verified email's Person and links it", async () => {
884
+ users().push({ id: U, email: " Me@X.io ", emailVerified: true });
885
+ const result = await ensureMemberPerson(WS_A1, U);
886
+ expect(result.ok).toBe(true);
887
+ expect(people()).toHaveLength(1);
888
+ expect(people()[0]).toEqual(expect.objectContaining({ email: "me@x.io", userId: U }));
889
+ });
890
+
891
+ it("ensureMemberPerson finds the existing Person of that email and links it", async () => {
892
+ const p = seedPerson({ email: "me@x.io" });
893
+ users().push({ id: U, email: "me@x.io", emailVerified: true });
894
+ const result = await ensureMemberPerson(WS_A1, U);
895
+ expect(result).toEqual({ ok: true, personId: p["id"] });
896
+ expect(people()).toHaveLength(1);
897
+ expect(people()[0]!["userId"]).toBe(U);
898
+ });
899
+
900
+ it("ensureMemberPerson is unavailable for an unverified caller and for a Person linked to another user", async () => {
901
+ users().push({ id: U, email: "me@x.io", emailVerified: false });
902
+ expect(await ensureMemberPerson(WS_A1, U)).toEqual({ ok: false });
903
+ users()[0]!["emailVerified"] = true;
904
+ seedPerson({ email: "me@x.io", userId: "user_00000000-0000-7000-8000-0000000000f4" });
905
+ expect(await ensureMemberPerson(WS_A1, U)).toEqual({ ok: false });
906
+ expect(people()[0]!["userId"]).toBe("user_00000000-0000-7000-8000-0000000000f4");
907
+ });
908
+
909
+ it("personContactFromRow serves a whole address or none, and every key even from a sparse row", () => {
910
+ expect(personContactFromRow({ ...(seedPerson({ email: "r@x.io", ...ADDRESS_COLUMNS }) as never) }).address).toEqual(ADDRESS);
911
+ expect(personContactFromRow({} as never)).toEqual({
912
+ givenName: null,
913
+ familyName: null,
914
+ telephone: null,
915
+ address: null,
916
+ companyName: null,
917
+ jobTitle: null,
918
+ });
919
+ expect(contactConfirmedFromRow({ contactConfirmedAt: null, contactConfirmedBy: null })).toBeNull();
920
+ expect(
921
+ contactConfirmedFromRow({ contactConfirmedAt: new Date("2026-10-12T00:00:00.000Z"), contactConfirmedBy: "client" }),
922
+ ).toEqual({ by: "client", at: "2026-10-12T00:00:00.000Z" });
923
+ });
924
+ });
925
+
534
926
  describe("setPersonPlanOffering (#7877 D8, AC-4, AC-5)", () => {
535
927
  it("sets and clears the plan, scoped to the workspace", async () => {
536
928
  const p = seedPerson({ email: "cleo@x.io" });
@@ -23,6 +23,7 @@ jest.mock("../../lib/prisma.js");
23
23
 
24
24
  import { prisma } from "../../lib/prisma.js";
25
25
  import { routeLead } from "../lead-routing.js";
26
+ import * as personService from "../person.js";
26
27
  import {
27
28
  CONFIRM_TOKEN_TTL_MS,
28
29
  claimConfirmation,
@@ -218,6 +219,92 @@ describe("recordPaymentLink()", () => {
218
219
  data: { stripePaymentLinkId: "plink_abc" },
219
220
  });
220
221
  });
222
+
223
+ // ── #8435 D5 (AC-3) — the Person step, after the Payment Link exists ────
224
+ describe("links the confirmed callback to its Person", () => {
225
+ const WORKSPACE = "wksp_00000000-0000-7000-8000-0000000084e5";
226
+ const PERSON = "person_00000000-0000-7000-8000-0000000084e5";
227
+ const LINKED = {
228
+ ok: true as const,
229
+ value: { personId: PERSON as never, created: true, billing: "unchanged" as const },
230
+ };
231
+ const row = (overrides: Record<string, unknown> = {}) => ({
232
+ workspaceId: WORKSPACE,
233
+ buyerEmail: "Buyer@Example.com",
234
+ name: "Bea",
235
+ phone: "+1 415 555 0123",
236
+ personId: null,
237
+ ...overrides,
238
+ });
239
+ let find: jest.SpyInstance;
240
+
241
+ beforeEach(() => {
242
+ find = jest.spyOn(personService, "findOrCreatePerson").mockResolvedValue(LINKED);
243
+ });
244
+ afterEach(() => find.mockRestore());
245
+
246
+ it("writes the link id first, then finds the buyer email's Person in the row's own workspace and stamps it once, guarded on personId: null", async () => {
247
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row());
248
+ await recordPaymentLink(CALLBACK_ID, "plink_abc");
249
+
250
+ expect(find).toHaveBeenCalledTimes(1);
251
+ expect(find).toHaveBeenCalledWith(WORKSPACE, {
252
+ email: "Buyer@Example.com",
253
+ name: "Bea",
254
+ contact: { telephone: "+14155550123" },
255
+ });
256
+ expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
257
+ expect(prisma.callback.updateMany).toHaveBeenCalledWith({
258
+ where: { id: CALLBACK_ID, workspaceId: WORKSPACE, personId: null },
259
+ data: { personId: PERSON },
260
+ });
261
+ const order = (fn: jest.Mock) => fn.mock.invocationCallOrder[0]!;
262
+ expect(order(prisma.callback.update as jest.Mock)).toBeLessThan(order(prisma.callback.findFirst as jest.Mock));
263
+ });
264
+
265
+ it("makes no Person call for a callback a capture already linked", async () => {
266
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row({ personId: PERSON }));
267
+ await recordPaymentLink(CALLBACK_ID, "plink_abc");
268
+ expect(find).not.toHaveBeenCalled();
269
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("makes no Person call for a row with no buyer email", async () => {
273
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row({ buyerEmail: null }));
274
+ await recordPaymentLink(CALLBACK_ID, "plink_abc");
275
+ expect(find).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it("a Person step that throws still leaves the link id written and recordPaymentLink resolving", async () => {
279
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row());
280
+ find.mockRejectedValue(new Error("down"));
281
+ await expect(recordPaymentLink(CALLBACK_ID, "plink_abc")).resolves.toBeUndefined();
282
+ expect(prisma.callback.update).toHaveBeenCalledWith({
283
+ where: { id: CALLBACK_ID },
284
+ data: { stripePaymentLinkId: "plink_abc" },
285
+ });
286
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
287
+ });
288
+
289
+ it("a read that throws is swallowed the same way", async () => {
290
+ (prisma.callback.findFirst as jest.Mock).mockRejectedValue(new Error("down"));
291
+ await expect(recordPaymentLink(CALLBACK_ID, "plink_abc")).resolves.toBeUndefined();
292
+ });
293
+
294
+ it("claimConfirmation makes no person delegate call at all — the token is spent before any Person work", async () => {
295
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row());
296
+ await claimConfirmation({
297
+ id: CALLBACK_ID,
298
+ token: "a-token",
299
+ confirmedTime: "Friday 3pm",
300
+ buyerEmail: "buyer@example.com",
301
+ });
302
+ expect(find).not.toHaveBeenCalled();
303
+ expect(jest.mocked(prisma.person.findFirst)).not.toHaveBeenCalled();
304
+ expect(jest.mocked(prisma.person.createMany)).not.toHaveBeenCalled();
305
+ expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
306
+ });
307
+ });
221
308
  });
222
309
 
223
310
  // ── Configuration ──────────────────────────────────────────────────────────
@@ -12,6 +12,12 @@
12
12
  * The server binds 127.0.0.1, which `ssrfBlocked` rejects; D7's
13
13
  * `allowPrivate: true` is what makes this substrate runnable at all.
14
14
  *
15
+ * The backoff schedule is asserted through the injected `sleep` seam, never
16
+ * against wall-clock: the delays a case observes are the ones the service asked
17
+ * for, so a loaded runner cannot stretch or shrink them (#7904, #8418). And the
18
+ * server answers every request `connection: close`, so no attempt can land on
19
+ * a pooled socket the server has already dropped (#8418).
20
+ *
15
21
  * `CONTENT_REVALIDATE_*` are read through `config`, which snapshots
16
22
  * `process.env` at import time — so every case sets env first and then
17
23
  * `require`s the service through `jest.resetModules()`.
@@ -73,7 +79,6 @@ interface Recorded {
73
79
  eventHeader: string;
74
80
  timestampHeader: string;
75
81
  contentType: string;
76
- receivedAt: number;
77
82
  }
78
83
 
79
84
  let server: Server;
@@ -81,6 +86,10 @@ let serverUrl: string;
81
86
  let recorded: Recorded[] = [];
82
87
  /** Status codes the server replies with, one per request, in order. */
83
88
  let responseStatuses: number[] = [];
89
+ /** Every backoff the service asked to sleep for, in order. */
90
+ let sleeps: number[] = [];
91
+ /** The injected sleep seam: records the backoff and resolves at once. */
92
+ const sleep = async (ms: number): Promise<void> => void sleeps.push(ms);
84
93
 
85
94
  beforeAll(async () => {
86
95
  server = createServer((req, res) => {
@@ -123,10 +132,16 @@ beforeAll(async () => {
123
132
  eventHeader,
124
133
  timestampHeader,
125
134
  contentType: String(req.headers["content-type"] ?? ""),
126
- receivedAt: Date.now(),
127
135
  });
128
136
 
129
- res.writeHead(responseStatuses[recorded.length - 1] ?? 200);
137
+ // `connection: close` — one TCP connection per request. With keep-alive,
138
+ // fetch reuses a pooled socket, and when the server's idle timeout drops
139
+ // that socket as the next request goes out, the attempt fails on the
140
+ // client (UND_ERR_SOCKET) and never reaches this handler: the lost
141
+ // attempt behind #8418's flake on the #8417 release PR.
142
+ res.writeHead(responseStatuses[recorded.length - 1] ?? 200, {
143
+ connection: "close",
144
+ });
130
145
  res.end();
131
146
  });
132
147
  });
@@ -151,7 +166,14 @@ beforeEach(() => {
151
166
  process.env.CONTENT_REVALIDATE_SECRET = TEST_SECRET;
152
167
  recorded = [];
153
168
  responseStatuses = [];
169
+ sleeps = [];
154
170
  mockLogLines.length = 0;
171
+ // Drop any connection a previous case left idle — the server-side
172
+ // keep-alive close that ate attempt 1 on the CI shard, made to happen at the
173
+ // start of every case. Under `connection: close` there is never one to drop;
174
+ // without the header, the next case's first attempt lands on the dropped
175
+ // socket and a counting case below fails on every run, not 1-in-N on CI.
176
+ server.closeIdleConnections();
155
177
  });
156
178
 
157
179
  afterEach(() => {
@@ -193,35 +215,35 @@ describe("notifyRevalidate", () => {
193
215
  it("retries a 5xx with D3 backoff and succeeds on the third attempt", async () => {
194
216
  responseStatuses = [500, 500, 200];
195
217
 
196
- await loadService().notifyRevalidate(TARGET, { event: "unpublish" });
218
+ await loadService().notifyRevalidate(TARGET, {
219
+ event: "unpublish",
220
+ sleep,
221
+ });
197
222
 
198
223
  expect(recorded).toHaveLength(3);
199
- // D3 — 500 ms before attempt 2, 2000 ms before attempt 3. Bounded on BOTH
200
- // sides: a lower bound alone would pass an arbitrarily long backoff, and
201
- // the whole point of D3's schedule is that it fits the 60 s publish budget.
202
- // The upper slack absorbs loopback scheduling jitter, nothing more.
203
- const gapA = recorded[1].receivedAt - recorded[0].receivedAt;
204
- const gapB = recorded[2].receivedAt - recorded[1].receivedAt;
205
- expect(gapA).toBeGreaterThanOrEqual(500);
206
- expect(gapA).toBeLessThan(2000);
207
- expect(gapB).toBeGreaterThanOrEqual(2000);
208
- expect(gapB).toBeLessThan(4000);
224
+ // D3 — exactly 500 ms before attempt 2 and 2000 ms before attempt 3, the
225
+ // schedule that fits the 60 s publish budget. Asserted as the delays the
226
+ // service scheduled, not as measured gaps a loaded runner can stretch
227
+ // (#7904: a 2122 ms gapA failed the old `< 2000` bound).
228
+ expect(sleeps).toEqual([500, 2000]);
209
229
  expect(recorded.every((r) => r.signatureValid)).toBe(true);
210
230
 
211
231
  expect(
212
232
  mockLogLines.filter((l) => l.msg === "revalidate.sent"),
213
233
  ).toHaveLength(1);
214
234
  expect(mockLogLines.some((l) => l.level === "error")).toBe(false);
215
- }, 20_000);
235
+ });
216
236
 
217
237
  it("stops at three attempts, logs the failure at error, and still resolves", async () => {
218
238
  responseStatuses = [500, 500, 500];
219
239
 
220
240
  await expect(
221
- loadService().notifyRevalidate(TARGET, { event: "publish" }),
241
+ loadService().notifyRevalidate(TARGET, { event: "publish", sleep }),
222
242
  ).resolves.toBeUndefined();
223
243
 
224
244
  expect(recorded).toHaveLength(3);
245
+ // Two backoffs and no third: running out of the schedule IS the bound.
246
+ expect(sleeps).toEqual([500, 2000]);
225
247
 
226
248
  const perAttempt = mockLogLines.filter(
227
249
  (l) => l.level === "warn" && l.msg === "revalidate.failed",
@@ -238,7 +260,25 @@ describe("notifyRevalidate", () => {
238
260
  attempts: 3,
239
261
  lastStatus: 500,
240
262
  });
241
- }, 20_000);
263
+ });
264
+
265
+ it("waits out the backoff on the real timer when no sleep is injected", async () => {
266
+ responseStatuses = [500, 200];
267
+ const setTimeoutSpy = jest.spyOn(global, "setTimeout");
268
+ let delays: unknown[];
269
+
270
+ try {
271
+ await loadService().notifyRevalidate(TARGET, { event: "publish" });
272
+ delays = setTimeoutSpy.mock.calls.map(([, ms]) => ms);
273
+ } finally {
274
+ setTimeoutSpy.mockRestore();
275
+ }
276
+
277
+ // The production default: the one backoff is a real 500 ms timer. Asserted
278
+ // as the delay scheduled, never as a measured gap.
279
+ expect(recorded).toHaveLength(2);
280
+ expect(delays).toContain(500);
281
+ });
242
282
 
243
283
  it("signs v0:{timestamp}:{event}:{body} and sends the timestamp header (#5902)", async () => {
244
284
  responseStatuses = [200];