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
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Tests for /[locale]/account/onboarding — the client's onboarding page
3
+ * (#8515, spec docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md
4
+ * D12; AC-7).
5
+ *
6
+ * The page runs its real server actions (`onboarding/actions.ts`) against the
7
+ * mocked member read and write, as `popups.test.tsx` runs the New menu's.
8
+ *
9
+ * Covers:
10
+ * 1. With the standard onboarding form (D1's five questions) the page
11
+ * renders a text input `answer.q0`, a radio group `answer.q1` of four
12
+ * options, five checkboxes `answer.q2`, a URL input `answer.q3` and a
13
+ * text input `answer.q4`.
14
+ * 2. Submitting with `q2` checked twice posts `{ answers }` once, the multi
15
+ * answer an array, a blank optional answer omitted, and the sent status
16
+ * replaces the form. One checked box still posts an array.
17
+ * 3. A `409 onboarding_off` refusal shows its copy and keeps the form and
18
+ * every answer.
19
+ * 4. `form: null` shows the unavailable copy and a link back to the
20
+ * locale's account; a failed read shows the error row with Retry.
21
+ * 5. No session is a not-found, and nothing is read.
22
+ */
23
+ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
24
+
25
+ jest.mock("next-intl/server", () => {
26
+ const msgs: unknown = require("../../../../../messages/en.json");
27
+ const dig = (obj: unknown, path: string): unknown =>
28
+ path.split(".").reduce<unknown>(
29
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
30
+ obj,
31
+ );
32
+ return {
33
+ __esModule: true,
34
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
35
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
36
+ const messages = dig(msgs, ns);
37
+ return (key: string, values?: Record<string, unknown>): string => {
38
+ const value = dig(messages, key);
39
+ if (typeof value !== "string") return `${ns}.${key}`;
40
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
41
+ values && name in values ? String(values[name]) : m,
42
+ );
43
+ };
44
+ },
45
+ };
46
+ });
47
+
48
+ jest.mock("next/headers", () => ({
49
+ __esModule: true,
50
+ headers: jest.fn().mockResolvedValue(new Headers()),
51
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
52
+ }));
53
+
54
+ const mockGetSession = jest.fn();
55
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
56
+ jest.mock("../../../../../auth", () => ({
57
+ __esModule: true,
58
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
59
+ }));
60
+
61
+ const mockMemberOnboarding = jest.fn();
62
+ const mockMemberSubmitOnboarding = jest.fn();
63
+ const mockMemberRequestQuote = jest.fn();
64
+ jest.mock("@working-theory/api-client", () => ({
65
+ __esModule: true,
66
+ waitlistSignups: { me: jest.fn() },
67
+ subscription: { me: jest.fn() },
68
+ member: {
69
+ onboarding: (...args: unknown[]) => mockMemberOnboarding(...args),
70
+ submitOnboarding: (...args: unknown[]) => mockMemberSubmitOnboarding(...args),
71
+ requestQuote: (...args: unknown[]) => mockMemberRequestQuote(...args),
72
+ },
73
+ }));
74
+
75
+ const mockNotFound = jest.fn(() => {
76
+ throw new Error("NEXT_NOT_FOUND");
77
+ });
78
+ const mockRefresh = jest.fn();
79
+ jest.mock("next/navigation", () => ({
80
+ __esModule: true,
81
+ notFound: () => mockNotFound(),
82
+ useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
83
+ usePathname: () => "/account/onboarding",
84
+ }));
85
+
86
+ import {
87
+ memberOnboardingFormResponseSchema,
88
+ onboardingFlowConfig,
89
+ type MemberOnboardingFormResponse,
90
+ } from "@working-theory/validation";
91
+
92
+ import Page from "../onboarding/page";
93
+
94
+ const SESSION = {
95
+ user: { id: "user_1", name: "Cleo", email: "cleo@x.io", emailVerified: true },
96
+ };
97
+
98
+ /** The cookie a single site forwards (`forwardedCookieInit()`). */
99
+ const INIT = { headers: { cookie: "session=abc" } };
100
+
101
+ /** D1's form as `GET /v1/member/onboarding` serves it (D7). */
102
+ const FORM: MemberOnboardingFormResponse = memberOnboardingFormResponseSchema.parse({
103
+ form: {
104
+ title: "Client onboarding",
105
+ defaultLocale: "en",
106
+ steps: onboardingFlowConfig.steps,
107
+ },
108
+ });
109
+
110
+ const ok = <T,>(body: T) => ({ ok: true, body });
111
+ const refusal = (status: number, code: string) => ({
112
+ ok: false,
113
+ status,
114
+ body: { ok: false, error: { code, message: "x" } },
115
+ });
116
+
117
+ beforeEach(() => {
118
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
119
+ mockMemberOnboarding.mockReset().mockResolvedValue(ok(FORM));
120
+ mockMemberSubmitOnboarding
121
+ .mockReset()
122
+ .mockResolvedValue(ok({ id: "lead_1", createdAt: "2026-10-14T10:00:00.000Z" }));
123
+ mockMemberRequestQuote.mockReset();
124
+ mockNotFound.mockClear();
125
+ mockRefresh.mockReset();
126
+ });
127
+
128
+ async function renderPage(locale = "fr"): Promise<HTMLElement> {
129
+ const element = await Page({ params: Promise.resolve({ locale }) });
130
+ return render(element).container;
131
+ }
132
+
133
+ function named(container: HTMLElement, name: string): HTMLInputElement[] {
134
+ return Array.from(container.querySelectorAll<HTMLInputElement>(`[name="${name}"]`));
135
+ }
136
+
137
+ function type(container: HTMLElement, name: string, value: string): void {
138
+ const [control] = named(container, name);
139
+ fireEvent.change(control!, { target: { value } });
140
+ }
141
+
142
+ /** Answer the four questions the fill cases share; `q4` is left to the case. */
143
+ function answerCommon(container: HTMLElement): void {
144
+ type(container, "answer.q0", "Grow");
145
+ fireEvent.click(screen.getByRole("radio", { name: "This month" }));
146
+ fireEvent.click(screen.getByRole("checkbox", { name: "Content" }));
147
+ fireEvent.click(screen.getByRole("checkbox", { name: "Website" }));
148
+ type(container, "answer.q3", "https://cleo.example");
149
+ }
150
+
151
+ describe("account onboarding page — the controls (AC-7)", () => {
152
+ it("renders one control per question, each named answer.<name>", async () => {
153
+ const container = await renderPage();
154
+ const form = screen.getByTestId("account-onboarding-form");
155
+
156
+ const [q0] = named(container, "answer.q0");
157
+ expect(q0!.tagName).toBe("INPUT");
158
+ expect(q0!.type).toBe("text");
159
+ expect(q0!.required).toBe(true);
160
+
161
+ const group = within(form).getByRole("radiogroup", { name: "When do you want to start?" });
162
+ expect(within(group).getAllByRole("radio").map((r) => r.getAttribute("value"))).toEqual([
163
+ "This week",
164
+ "This month",
165
+ "Later this quarter",
166
+ "Just exploring",
167
+ ]);
168
+ expect(named(container, "answer.q1")).toHaveLength(4);
169
+
170
+ const boxes = within(form).getByRole("group", { name: "Where do you want help?" });
171
+ expect(within(boxes).getAllByRole("checkbox")).toHaveLength(5);
172
+ expect(named(container, "answer.q2")).toHaveLength(5);
173
+ expect(named(container, "answer.q2").every((input) => input.type === "checkbox")).toBe(true);
174
+
175
+ const [q3] = named(container, "answer.q3");
176
+ expect(q3!.tagName).toBe("INPUT");
177
+ expect(q3!.inputMode).toBe("url");
178
+ expect(screen.getByText("Must start with https://.")).toBeInTheDocument();
179
+
180
+ const [q4] = named(container, "answer.q4");
181
+ expect(q4!.tagName).toBe("INPUT");
182
+ expect(q4!.type).toBe("text");
183
+
184
+ expect(screen.getByLabelText("What do you most want from working with us?")).toBe(q0);
185
+ expect(screen.getByLabelText(/Your website/)).toBe(q3);
186
+ expect(mockMemberOnboarding).toHaveBeenCalledWith(INIT);
187
+ });
188
+ });
189
+
190
+ describe("account onboarding page — submitting (AC-7)", () => {
191
+ it("posts the answers once, q2 as an array, and shows the sent status", async () => {
192
+ const container = await renderPage();
193
+ answerCommon(container);
194
+ type(container, "answer.q4", "Thanks");
195
+ fireEvent.click(screen.getByTestId("account-onboarding-submit"));
196
+
197
+ const sent = await screen.findByTestId("account-onboarding-sent");
198
+ expect(sent).toHaveAttribute("role", "status");
199
+ expect(sent).toHaveTextContent("Thank you — your answers were sent.");
200
+ expect(screen.queryByTestId("account-onboarding-form")).not.toBeInTheDocument();
201
+
202
+ expect(mockMemberSubmitOnboarding).toHaveBeenCalledTimes(1);
203
+ expect(mockMemberSubmitOnboarding).toHaveBeenCalledWith(
204
+ {
205
+ answers: {
206
+ q0: "Grow",
207
+ q1: "This month",
208
+ q2: ["Content", "Website"],
209
+ q3: "https://cleo.example",
210
+ q4: "Thanks",
211
+ },
212
+ },
213
+ INIT,
214
+ );
215
+ expect(mockMemberRequestQuote).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it("omits a blank optional answer", async () => {
219
+ const container = await renderPage();
220
+ answerCommon(container);
221
+ type(container, "answer.q4", " ");
222
+ fireEvent.click(screen.getByTestId("account-onboarding-submit"));
223
+
224
+ await screen.findByTestId("account-onboarding-sent");
225
+ expect(mockMemberSubmitOnboarding).toHaveBeenCalledTimes(1);
226
+ expect(mockMemberSubmitOnboarding.mock.calls[0]![0]).toEqual({
227
+ answers: {
228
+ q0: "Grow",
229
+ q1: "This month",
230
+ q2: ["Content", "Website"],
231
+ q3: "https://cleo.example",
232
+ },
233
+ });
234
+ });
235
+
236
+ it("posts one checked box of a multi question as an array", async () => {
237
+ const container = await renderPage();
238
+ type(container, "answer.q0", "Grow");
239
+ fireEvent.click(screen.getByRole("radio", { name: "This week" }));
240
+ fireEvent.click(screen.getByRole("checkbox", { name: "Sales" }));
241
+ fireEvent.click(screen.getByTestId("account-onboarding-submit"));
242
+
243
+ await screen.findByTestId("account-onboarding-sent");
244
+ expect(mockMemberSubmitOnboarding.mock.calls[0]![0]).toEqual({
245
+ answers: { q0: "Grow", q1: "This week", q2: ["Sales"] },
246
+ });
247
+ });
248
+
249
+ it("a 409 onboarding_off shows its copy and keeps the form and every answer", async () => {
250
+ mockMemberSubmitOnboarding.mockResolvedValue(refusal(409, "onboarding_off"));
251
+ const container = await renderPage();
252
+ answerCommon(container);
253
+ fireEvent.click(screen.getByTestId("account-onboarding-submit"));
254
+
255
+ const error = await screen.findByTestId("account-onboarding-error");
256
+ expect(error).toHaveTextContent("Onboarding isn't open to you here right now.");
257
+ expect(screen.getByTestId("account-onboarding-form")).toBeInTheDocument();
258
+ expect(screen.queryByTestId("account-onboarding-sent")).not.toBeInTheDocument();
259
+ expect(named(container, "answer.q0")[0]!.value).toBe("Grow");
260
+ expect(screen.getByRole("radio", { name: "This month" })).toHaveAttribute("aria-checked", "true");
261
+ expect(screen.getByRole("checkbox", { name: "Content" })).toHaveAttribute("aria-checked", "true");
262
+ expect(named(container, "answer.q3")[0]!.value).toBe("https://cleo.example");
263
+ });
264
+
265
+ it.each([
266
+ ["answers_invalid", 400, "Some answers do not fit these questions. Check them and try again."],
267
+ ["link_not_https", 400, "A link must start with https://."],
268
+ ["too_many_requests", 429, "Too many submissions. Try again later."],
269
+ ["something_else", 500, "Your answers could not be sent. Try again."],
270
+ ])("a %s refusal shows its own copy", async (code, status, copy) => {
271
+ mockMemberSubmitOnboarding.mockResolvedValue(refusal(status, code));
272
+ const container = await renderPage();
273
+ answerCommon(container);
274
+ fireEvent.click(screen.getByTestId("account-onboarding-submit"));
275
+
276
+ await waitFor(() =>
277
+ expect(screen.getByTestId("account-onboarding-error")).toHaveTextContent(copy),
278
+ );
279
+ expect(screen.getByTestId("account-onboarding-form")).toBeInTheDocument();
280
+ });
281
+ });
282
+
283
+ describe("account onboarding page — the other states", () => {
284
+ it("form: null shows the unavailable copy and a link back to the locale's account", async () => {
285
+ mockMemberOnboarding.mockResolvedValue(ok({ form: null }));
286
+ await renderPage("fr");
287
+ expect(screen.getByTestId("account-onboarding-unavailable")).toHaveTextContent(
288
+ "Onboarding isn't open to you here right now.",
289
+ );
290
+ expect(screen.getByRole("link", { name: "Back to your account" })).toHaveAttribute(
291
+ "href",
292
+ "/fr/account",
293
+ );
294
+ expect(screen.queryByTestId("account-onboarding-form")).not.toBeInTheDocument();
295
+ });
296
+
297
+ it("a 404 read (no member area) is the unavailable state, not an error", async () => {
298
+ mockMemberOnboarding.mockResolvedValue(refusal(404, "not_found"));
299
+ await renderPage();
300
+ expect(screen.getByTestId("account-onboarding-unavailable")).toBeInTheDocument();
301
+ });
302
+
303
+ it("a failed read renders the error row with Retry", async () => {
304
+ mockMemberOnboarding.mockResolvedValue(refusal(503, "tenant_unresolved"));
305
+ await renderPage();
306
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent(
307
+ "We couldn't load your onboarding questions.",
308
+ );
309
+ expect(screen.queryByTestId("account-onboarding-form")).not.toBeInTheDocument();
310
+ fireEvent.click(screen.getByTestId("inbox-retry"));
311
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
312
+ });
313
+
314
+ it("no session renders the heading alone — never a not-found over the sign-in gate — and reads nothing", async () => {
315
+ mockGetSession.mockResolvedValue(null);
316
+ await renderPage();
317
+ expect(mockNotFound).not.toHaveBeenCalled();
318
+ expect(screen.getByTestId("account-onboarding")).toHaveTextContent("Onboarding");
319
+ expect(screen.queryByTestId("account-onboarding-form")).not.toBeInTheDocument();
320
+ expect(screen.queryByTestId("account-onboarding-unavailable")).not.toBeInTheDocument();
321
+ expect(mockMemberOnboarding).not.toHaveBeenCalled();
322
+ });
323
+ });
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Tests for /[locale]/account/privacy — User settings › Privacy (#8438
3
+ * D14, AC-4). The deletion-request cases moved here from general.test.tsx,
4
+ * expectations unchanged (#8438 D16).
5
+ */
6
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
11
+
12
+ jest.mock("next-intl/server", () => {
13
+ const msgs: unknown = require("../../../../../messages/en.json");
14
+ const dig = (obj: unknown, path: string): unknown =>
15
+ path.split(".").reduce<unknown>(
16
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
17
+ obj,
18
+ );
19
+ return {
20
+ __esModule: true,
21
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
22
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
23
+ const messages = dig(msgs, ns);
24
+ return (key: string, values?: Record<string, unknown>): string => {
25
+ const value = dig(messages, key);
26
+ if (typeof value !== "string") return `${ns}.${key}`;
27
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
28
+ values && name in values ? String(values[name]) : m,
29
+ );
30
+ };
31
+ },
32
+ getLocale: jest.fn().mockResolvedValue("en"),
33
+ };
34
+ });
35
+
36
+ const mockHeaders = jest.fn();
37
+ const mockCookies: Record<string, string> = {};
38
+ jest.mock("next/headers", () => ({
39
+ __esModule: true,
40
+ headers: (...args: unknown[]) => mockHeaders(...args),
41
+ cookies: jest.fn().mockResolvedValue({
42
+ toString: () => "session=abc",
43
+ get: (name: string) => (name in mockCookies ? { name, value: mockCookies[name] } : undefined),
44
+ }),
45
+ }));
46
+
47
+ const mockGetSession = jest.fn();
48
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
49
+ jest.mock("../../../../../auth", () => ({
50
+ __esModule: true,
51
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
52
+ }));
53
+
54
+ const mockPieces = jest.fn();
55
+ const mockRequestDeletion = jest.fn();
56
+ const mockConsents = jest.fn();
57
+ const mockSetConsent = jest.fn();
58
+ jest.mock("@working-theory/api-client", () => ({
59
+ __esModule: true,
60
+ waitlistSignups: { me: jest.fn() },
61
+ flowCheckouts: { me: jest.fn() },
62
+ subscription: { me: jest.fn(), portalSession: jest.fn() },
63
+ leads: { submit: jest.fn() },
64
+ member: {
65
+ pieces: (...args: unknown[]) => mockPieces(...args),
66
+ requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
67
+ consents: (...args: unknown[]) => mockConsents(...args),
68
+ setConsent: (...args: unknown[]) => mockSetConsent(...args),
69
+ },
70
+ hubSignIn: { me: jest.fn() },
71
+ visitorForwardHeaders: () => null,
72
+ }));
73
+
74
+ jest.mock("next/navigation", () => ({
75
+ __esModule: true,
76
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
77
+ usePathname: () => "/account/privacy",
78
+ redirect: jest.fn(),
79
+ }));
80
+
81
+ import Page from "../privacy/page";
82
+
83
+ const SESSION = {
84
+ user: {
85
+ id: "user_1",
86
+ name: "Ada Lovelace",
87
+ email: "ada@example.com",
88
+ emailVerified: true,
89
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
90
+ },
91
+ };
92
+ const COOKIE_INIT = { headers: { cookie: "session=abc" } };
93
+ const ok = <T,>(body: T) => ({ ok: true, body });
94
+ const refusal = (status: number, code: string) => ({
95
+ ok: false,
96
+ status,
97
+ body: { ok: false, error: { code, message: code } },
98
+ });
99
+
100
+ function dataPurpose(overrides: Partial<{ id: string; effective: boolean; state: unknown }> = {}) {
101
+ return {
102
+ id: overrides.id ?? "data_sharing",
103
+ kind: "consent" as const,
104
+ area: "data" as const,
105
+ required: false,
106
+ signUp: false,
107
+ description: "Product-usage data.",
108
+ statement: "Share product-usage data with us.",
109
+ policyHref: "/privacy",
110
+ policyVersion: 1,
111
+ state: overrides.state === undefined ? null : overrides.state,
112
+ effective: overrides.effective ?? false,
113
+ };
114
+ }
115
+
116
+ function agreementPurpose(overrides: Partial<{ id: string; state: unknown }> = {}) {
117
+ return {
118
+ id: overrides.id ?? "account_terms",
119
+ kind: "agreement" as const,
120
+ area: null,
121
+ required: true,
122
+ signUp: true,
123
+ description: "Account terms.",
124
+ statement: "You agree to our Terms and Privacy Policy.",
125
+ policyHref: "/terms",
126
+ policyVersion: 1,
127
+ state: overrides.state === undefined ? null : overrides.state,
128
+ effective: false,
129
+ };
130
+ }
131
+
132
+ let contentRoot: string;
133
+
134
+ beforeEach(() => {
135
+ contentRoot = mkdtempSync(join(tmpdir(), "ailk-privacy-"));
136
+ process.env.AILK_CONTENT_ROOT = contentRoot;
137
+ writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
138
+ mockHeaders.mockReset().mockResolvedValue(new Headers());
139
+ for (const key of Object.keys(mockCookies)) delete mockCookies[key];
140
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
141
+ mockPieces.mockReset().mockResolvedValue(ok([]));
142
+ mockRequestDeletion.mockReset().mockResolvedValue(ok({ sentTo: "site-operator" }));
143
+ mockConsents.mockReset().mockResolvedValue(ok({ purposes: [] }));
144
+ mockSetConsent.mockReset();
145
+ });
146
+
147
+ afterEach(() => {
148
+ rmSync(contentRoot, { recursive: true, force: true });
149
+ });
150
+
151
+ async function renderPage(locale = "en"): Promise<void> {
152
+ render(await Page({ params: Promise.resolve({ locale }) }));
153
+ }
154
+
155
+ describe("AC-4 — the frame", () => {
156
+ it("the Settings rail reads General, Security, Notifications, Privacy, Billing, Privacy current", async () => {
157
+ await renderPage();
158
+ const rail = screen.getByRole("navigation", { name: "Settings" });
159
+ const links = within(rail).getAllByRole("link");
160
+ expect(links.map((link) => link.textContent)).toEqual([
161
+ "General",
162
+ "Security",
163
+ "Notifications",
164
+ "Privacy",
165
+ "Billing",
166
+ ]);
167
+ expect(screen.getByTestId("account-settings-rail-privacy")).toHaveAttribute("aria-current", "page");
168
+ });
169
+
170
+ it("a non-default locale prefixes the rail", async () => {
171
+ await renderPage("fr");
172
+ expect(screen.getByTestId("account-settings-rail-privacy")).toHaveAttribute("href", "/fr/account/privacy");
173
+ });
174
+ });
175
+
176
+ describe("AC-4 — DOM order: summary, cookies, consents, agreements, deletion", () => {
177
+ it("renders the five sections in order", async () => {
178
+ mockConsents.mockResolvedValue(
179
+ ok({ purposes: [dataPurpose(), agreementPurpose()] }),
180
+ );
181
+ await renderPage();
182
+ const pane = screen.getByTestId("account-settings-privacy");
183
+ const ids = [
184
+ "account-privacy-summary",
185
+ "account-privacy-cookies",
186
+ "account-privacy-consents",
187
+ "account-privacy-agreements",
188
+ "account-deletion",
189
+ ];
190
+ const positions = ids.map((id) => {
191
+ const el = within(pane).getByTestId(id);
192
+ let index = 0;
193
+ let node: Element | null = pane.firstElementChild;
194
+ while (node && node !== el) {
195
+ node = node.nextElementSibling;
196
+ index += 1;
197
+ }
198
+ return index;
199
+ });
200
+ expect(positions).toEqual([...positions].sort((a, b) => a - b));
201
+ });
202
+ });
203
+
204
+ describe("AC-4 — account-privacy-summary", () => {
205
+ it("links to the site's privacy policy", async () => {
206
+ await renderPage();
207
+ expect(screen.getByTestId("account-privacy-policy")).toHaveAttribute("href", "/privacy");
208
+ });
209
+ });
210
+
211
+ describe("AC-4 — account-privacy-cookies (read from the browser cookie, not the ledger)", () => {
212
+ it.each([
213
+ ["accepted", "Accepted"],
214
+ ["declined", "Declined"],
215
+ ])("reads %s as %s", async (value, label) => {
216
+ mockCookies["ailk-consent"] = value;
217
+ await renderPage();
218
+ expect(within(screen.getByTestId("account-privacy-cookies")).getByText(label)).toBeInTheDocument();
219
+ });
220
+
221
+ it("with no cookie set, reads Not chosen yet", async () => {
222
+ await renderPage();
223
+ expect(within(screen.getByTestId("account-privacy-cookies")).getByText("Not chosen yet")).toBeInTheDocument();
224
+ });
225
+
226
+ it("the Change link points at /privacy#cookie-settings", async () => {
227
+ await renderPage();
228
+ expect(screen.getByTestId("account-cookie-settings")).toHaveAttribute("href", "/privacy#cookie-settings");
229
+ });
230
+ });
231
+
232
+ describe("AC-4 — account-privacy-consents (data-use switches)", () => {
233
+ it("a declared data purpose renders a switch with its date", async () => {
234
+ mockConsents.mockResolvedValue(
235
+ ok({
236
+ purposes: [
237
+ dataPurpose({
238
+ effective: true,
239
+ state: { granted: true, at: "2026-10-12T12:00:00.000Z", source: "account_settings", policyVersion: 1 },
240
+ }),
241
+ ],
242
+ }),
243
+ );
244
+ await renderPage();
245
+ expect(screen.getByTestId("account-privacy-data-consent-data_sharing-switch")).toBeChecked();
246
+ expect(screen.getByTestId("account-privacy-data-consent-data_sharing-date")).toHaveTextContent(
247
+ "Given Oct 12, 2026",
248
+ );
249
+ });
250
+
251
+ it("toggling calls setConsentAction, and a failed answer reverts the switch with its message", async () => {
252
+ mockConsents.mockResolvedValue(ok({ purposes: [dataPurpose({ effective: true })] }));
253
+ mockSetConsent.mockResolvedValue(refusal(500, "internal"));
254
+ await renderPage();
255
+ fireEvent.click(screen.getByTestId("account-privacy-data-consent-data_sharing-switch"));
256
+ await waitFor(() =>
257
+ expect(screen.getByTestId("account-privacy-data-consent-data_sharing-error")).toBeInTheDocument(),
258
+ );
259
+ expect(screen.getByTestId("account-privacy-data-consent-data_sharing-switch")).toBeChecked();
260
+ expect(mockSetConsent).toHaveBeenCalledWith({ purposeId: "data_sharing", granted: false, locale: "en" }, COOKIE_INIT);
261
+ });
262
+
263
+ it("with no data purpose declared, shows the one line", async () => {
264
+ await renderPage();
265
+ expect(screen.getByTestId("account-privacy-data-consent-empty")).toHaveTextContent(
266
+ "This site asks for no other data-use consents.",
267
+ );
268
+ });
269
+ });
270
+
271
+ describe("AC-4 — account-privacy-agreements (read-only)", () => {
272
+ it("an accepted agreement shows its version and date, read-only", async () => {
273
+ mockConsents.mockResolvedValue(
274
+ ok({
275
+ purposes: [
276
+ agreementPurpose({
277
+ state: { granted: true, at: "2026-10-01T12:00:00.000Z", source: "sign_up", policyVersion: 1 },
278
+ }),
279
+ ],
280
+ }),
281
+ );
282
+ await renderPage();
283
+ const row = screen.getByTestId("account-privacy-agreement-account_terms");
284
+ expect(within(row).getByText("Version 1")).toBeInTheDocument();
285
+ expect(within(row).getByText("Accepted Oct 1, 2026")).toBeInTheDocument();
286
+ expect(within(row).queryByRole("switch")).not.toBeInTheDocument();
287
+ expect(within(row).queryByRole("button")).not.toBeInTheDocument();
288
+ });
289
+
290
+ it("an agreement with no grant shows None yet", async () => {
291
+ mockConsents.mockResolvedValue(ok({ purposes: [agreementPurpose()] }));
292
+ await renderPage();
293
+ expect(screen.getByTestId("account-privacy-agreement-account_terms-accepted")).toHaveTextContent("None yet");
294
+ });
295
+ });
296
+
297
+ describe("AC-4 — a failed consents read", () => {
298
+ it("keeps sections 1, 2 and 5 and shows the one error line in place of 3 and 4", async () => {
299
+ mockConsents.mockResolvedValue(refusal(503, "tenant_unresolved"));
300
+ await renderPage();
301
+ expect(screen.getByTestId("account-privacy-summary")).toBeInTheDocument();
302
+ expect(screen.getByTestId("account-privacy-cookies")).toBeInTheDocument();
303
+ expect(screen.getByTestId("account-deletion")).toBeInTheDocument();
304
+ expect(screen.queryByTestId("account-privacy-consents")).not.toBeInTheDocument();
305
+ expect(screen.queryByTestId("account-privacy-agreements")).not.toBeInTheDocument();
306
+ expect(screen.getByTestId("account-privacy-unavailable")).toHaveTextContent(
307
+ "Your consent choices can't be shown right now.",
308
+ );
309
+ });
310
+ });
311
+
312
+ // ── Deletion (moved from general.test.tsx, #8438 D16 — expectations unchanged) ──
313
+
314
+ describe("AC-4 — Request account deletion through the member API (moved from General, D16)", () => {
315
+ async function requestDeletion(): Promise<void> {
316
+ await act(async () => {
317
+ fireEvent.click(screen.getByTestId("account-deletion-request"));
318
+ });
319
+ }
320
+
321
+ it.each([
322
+ ["site-operator", "Sent to the site operator."],
323
+ ["hub-operator", "Sent to the hub operator."],
324
+ ])("an answer naming %s shows %s — the answer picks the line", async (sentTo, line) => {
325
+ mockRequestDeletion.mockResolvedValue(ok({ sentTo }));
326
+ await renderPage();
327
+ await requestDeletion();
328
+ expect(mockRequestDeletion).toHaveBeenCalledWith({}, COOKIE_INIT);
329
+ await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent(line));
330
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
331
+ });
332
+
333
+ it("a refusal shows its line and keeps the button", async () => {
334
+ mockRequestDeletion.mockResolvedValue(refusal(403, "email_unverified"));
335
+ await renderPage();
336
+ await requestDeletion();
337
+ await waitFor(() =>
338
+ expect(screen.getByRole("alert")).toHaveTextContent("Confirm your email address first, then ask again."),
339
+ );
340
+ expect(screen.getByTestId("account-deletion-request")).toBeEnabled();
341
+ });
342
+ });
343
+
344
+ describe("a single site with no member API (a 404 from the pieces read) — moved from general.test.tsx", () => {
345
+ it("points the deletion request at Support, with no Request button (C2, D5)", async () => {
346
+ mockPieces.mockResolvedValue(refusal(404, "not_found"));
347
+ await renderPage();
348
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
349
+ expect(screen.getByTestId("account-deletion-support")).toHaveAttribute("href", "/account/support");
350
+ expect(screen.getByText("To ask for your account to be deleted, contact support.")).toBeInTheDocument();
351
+ });
352
+ });
353
+
354
+ describe("the standalone shape (#8430)", () => {
355
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
356
+ await renderPage();
357
+ const settings = screen.getByTestId("account-settings");
358
+ expect(settings).toHaveClass("min-h-screen");
359
+ const column = within(settings).getByRole("complementary");
360
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
361
+ expect(within(settings).getByRole("main")).toContainElement(
362
+ screen.getByRole("heading", { level: 1 }),
363
+ );
364
+ });
365
+ });