create-ailk 0.11.1 → 0.11.3

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 (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -1,20 +1,25 @@
1
1
  /**
2
- * Tests for /[locale]/account — the respondent's own account page (#4984).
2
+ * Tests for /[locale]/account — the base account Inbox (#7430, spec 7423
3
+ * U4 · D6a, D9, D12; AC-7 as amended 2026-10-01).
3
4
  *
4
5
  * Covers:
5
- * 1. Results + Purchases both render from fixtures; a 404'd experiment key
6
- * is skipped (no throw, no row); a non-skippable error rethrows.
7
- * 2. Empty states: no configured experiment keys + `[]` checkouts.
8
- * 3. Profile: name, email, verified / unverified badge copy.
9
- * 4. SidebarNav carries the three in-page anchors (Profile / Results / Purchases).
10
- * 5. Sign-out: `signOut()` then `router.push("/")`.
6
+ * 1. The D6a tabs Recents · Suggested · Scheduled · All, each with its
7
+ * count (zeros included), Recents selected.
8
+ * 2. `result` and `purchase` rows sit under Recents and All, newest first,
9
+ * each linking to its item page.
10
+ * 3. `email-unverified` and `payment-due` are For-you items (the phone
11
+ * stack), never Inbox rows.
12
+ * 4. A member with no name gets an "Add your name" For-you item (needs-you,
13
+ * an Open action) linking to User settings › General.
14
+ * 5. A failed read renders an error row with Retry — never an empty Inbox.
11
15
  */
12
- import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
16
+ import { fireEvent, render, screen, within } from "@testing-library/react";
13
17
 
14
18
  // The global setup mock resolves only top-level namespace keys against
15
- // en.json; this page reads nested keys (`nav.profile`, `profile.verified`, …)
16
- // via `t("nav.profile")` — a call shape real next-intl supports — so resolve
17
- // dotted namespaces AND dotted keys here against the same catalog.
19
+ // en.json; the area reads nested keys (`inbox.tabs.recents`, …) — a call
20
+ // shape real next-intl supports — so resolve dotted namespaces AND dotted
21
+ // keys here against the same catalog. `{name}` placeholders are filled from
22
+ // `values` the way next-intl does.
18
23
  jest.mock("next-intl/server", () => {
19
24
  const msgs: unknown = require("../../../../../messages/en.json");
20
25
  const dig = (obj: unknown, path: string): unknown =>
@@ -27,9 +32,12 @@ jest.mock("next-intl/server", () => {
27
32
  getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
28
33
  const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
29
34
  const messages = dig(msgs, ns);
30
- return (key: string): string => {
35
+ return (key: string, values?: Record<string, unknown>): string => {
31
36
  const value = dig(messages, key);
32
- return typeof value === "string" ? value : `${ns}.${key}`;
37
+ if (typeof value !== "string") return `${ns}.${key}`;
38
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
39
+ values && name in values ? String(values[name]) : m,
40
+ );
33
41
  };
34
42
  },
35
43
  };
@@ -42,7 +50,7 @@ jest.mock("next/headers", () => ({
42
50
  }));
43
51
 
44
52
  const mockGetSession = jest.fn();
45
- // 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 profile fields can mock without standing up Prisma.
53
+ // 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.
46
54
  jest.mock("../../../../../auth", () => ({
47
55
  __esModule: true,
48
56
  auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
@@ -51,44 +59,31 @@ jest.mock("../../../../../auth", () => ({
51
59
  const mockSignupMe = jest.fn();
52
60
  const mockCheckoutsMe = jest.fn();
53
61
  const mockSubscriptionMe = jest.fn();
54
- const mockSubscriptionPortalSession = jest.fn();
55
62
  jest.mock("@working-theory/api-client", () => ({
56
63
  __esModule: true,
57
64
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
58
65
  flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
59
- subscription: {
60
- me: (...args: unknown[]) => mockSubscriptionMe(...args),
61
- portalSession: (...args: unknown[]) => mockSubscriptionPortalSession(...args),
62
- },
63
- }));
64
-
65
- let mockExperimentKeys: string[] = [];
66
- // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; account-config.ts is the OSS template's (empty) experiment registry — a pure constant with no external boundary to mock instead, overridden here to exercise the populated path.
67
- jest.mock("../../../../../lib/account-config", () => ({
68
- __esModule: true,
69
- get accountExperimentKeys() {
70
- return mockExperimentKeys;
71
- },
66
+ subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
72
67
  }));
73
68
 
74
- const mockSignOut = jest.fn();
75
- jest.mock("better-auth/react", () => ({
76
- __esModule: true,
77
- createAuthClient: () => ({ signOut: (...args: unknown[]) => mockSignOut(...args) }),
78
- }));
79
-
80
- const mockPush = jest.fn();
81
69
  const mockRefresh = jest.fn();
82
70
  jest.mock("next/navigation", () => ({
83
71
  __esModule: true,
84
- useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
85
- usePathname: () => "/en/account",
72
+ useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
73
+ usePathname: () => "/account",
86
74
  }));
87
75
 
76
+ import { accountExperimentKeys } from "../../../../../lib/account-config";
88
77
  import Page from "../page";
89
78
 
90
79
  const SESSION = {
91
- user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
80
+ user: {
81
+ id: "user_1",
82
+ name: "Ada",
83
+ email: "ada@example.com",
84
+ emailVerified: true,
85
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
86
+ },
92
87
  };
93
88
 
94
89
  const SIGNUP = {
@@ -101,244 +96,160 @@ const SIGNUP = {
101
96
  answers: { q3: null },
102
97
  };
103
98
 
104
- const NOT_FOUND = {
105
- ok: false,
106
- status: 404,
107
- body: { error: { code: "signup_not_found", message: "Not found" } },
99
+ const CHECKOUT = {
100
+ id: "fc_done",
101
+ flowSlug: "founder-flow",
102
+ experimentKey: "ailk-founder-diagnostic",
103
+ offerKey: "pro",
104
+ status: "completed",
105
+ amountMinor: 4900,
106
+ currency: "usd",
107
+ period: "monthly",
108
+ completedAt: "2026-09-04T10:00:00.000Z",
109
+ createdAt: "2026-09-04T09:58:00.000Z",
108
110
  };
109
111
 
110
- const CHECKOUTS = [
111
- {
112
- id: "fc_done",
113
- flowSlug: "founder-flow",
114
- experimentKey: "ailk-founder-diagnostic",
115
- offerKey: "pro",
116
- status: "completed",
117
- amountMinor: 4900,
118
- currency: "usd",
119
- period: "monthly",
120
- completedAt: "2026-09-04T10:00:00.000Z",
121
- createdAt: "2026-09-04T09:58:00.000Z",
122
- },
123
- {
124
- id: "fc_open",
125
- flowSlug: "founder-flow",
126
- experimentKey: "ailk-founder-diagnostic",
127
- offerKey: "starter",
128
- status: "created",
129
- amountMinor: 1900,
130
- currency: "usd",
131
- period: "monthly",
132
- completedAt: null,
133
- createdAt: "2026-09-05T08:00:00.000Z",
134
- },
135
- ];
112
+ const NO_SUBSCRIPTION = {
113
+ ok: true,
114
+ body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
115
+ };
136
116
 
137
- async function renderPage(): Promise<void> {
138
- const element = await Page({ params: Promise.resolve({ locale: "en" }) });
117
+ async function renderPage(locale = "en"): Promise<void> {
118
+ const element = await Page({ params: Promise.resolve({ locale }) });
139
119
  render(element);
140
120
  }
141
121
 
142
122
  beforeEach(() => {
123
+ accountExperimentKeys.length = 0;
143
124
  mockGetSession.mockReset().mockResolvedValue(SESSION);
144
- mockSignupMe.mockReset().mockImplementation(async (key: string) =>
145
- key === SIGNUP.experimentKey ? { ok: true, body: SIGNUP } : NOT_FOUND,
146
- );
147
- mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: CHECKOUTS });
148
- // Default: a healthy subscriber (no banner) — commerce-disabled builds
149
- // read identically (a 404), covered explicitly below (D8).
150
- mockSubscriptionMe.mockReset().mockResolvedValue({
151
- ok: true,
152
- body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
153
- });
154
- mockSignOut.mockReset().mockResolvedValue(undefined);
155
- mockPush.mockReset();
125
+ mockSignupMe.mockReset().mockResolvedValue({ ok: true, body: SIGNUP });
126
+ mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
127
+ mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
156
128
  mockRefresh.mockReset();
157
- mockExperimentKeys = ["ailk-founder-diagnostic", "beta-cohort"];
158
- });
159
-
160
- describe("AccountPage — results + purchases", () => {
161
- it("renders the signup for a configured key and skips a 404'd key", async () => {
162
- await renderPage();
163
-
164
- const results = screen.getByTestId("account-results");
165
- const row = within(results).getByTestId("account-result-ailk-founder-diagnostic");
166
- expect(within(row).getByText("hot")).toBeInTheDocument();
167
- expect(within(row).getByText("early_bird")).toBeInTheDocument();
168
- expect(within(results).queryByTestId("account-result-beta-cohort")).not.toBeInTheDocument();
169
- expect(within(results).queryByText("beta-cohort")).not.toBeInTheDocument();
170
-
171
- // One read per configured key, each forwarding the caller's cookie.
172
- expect(mockSignupMe).toHaveBeenCalledTimes(2);
173
- expect(mockSignupMe).toHaveBeenCalledWith("ailk-founder-diagnostic", {
174
- headers: { cookie: "session=abc" },
175
- });
176
- expect(mockSignupMe).toHaveBeenCalledWith("beta-cohort", {
177
- headers: { cookie: "session=abc" },
178
- });
179
- });
180
-
181
- it("renders every checkout, newest-first order preserved, with a formatted amount", async () => {
182
- await renderPage();
183
-
184
- const purchases = screen.getByTestId("account-purchases");
185
- const done = within(purchases).getByTestId("account-purchase-fc_done");
186
- const open = within(purchases).getByTestId("account-purchase-fc_open");
187
- expect(done).toBeInTheDocument();
188
- expect(open).toBeInTheDocument();
189
- expect(within(done).getByText("$49")).toBeInTheDocument();
190
- expect(within(done).getByText("completed")).toBeInTheDocument();
191
- expect(within(open).getByText("created")).toBeInTheDocument();
192
- expect(mockCheckoutsMe).toHaveBeenCalledWith({ headers: { cookie: "session=abc" } });
193
- });
194
-
195
- it("rethrows a non-skippable signup error to the route error boundary", async () => {
196
- mockSignupMe.mockResolvedValue({
197
- ok: false,
198
- status: 500,
199
- body: { error: { code: "internal_error", message: "Boom" } },
200
- });
201
- await expect(Page({ params: Promise.resolve({ locale: "en" }) })).rejects.toThrow("Boom");
202
- });
203
-
204
- it("treats a 403 email_unverified checkouts read as no purchases, not an error", async () => {
205
- mockCheckoutsMe.mockResolvedValue({
206
- ok: false,
207
- status: 403,
208
- body: { error: { code: "email_unverified", message: "Verify first" } },
209
- });
210
- await renderPage();
211
- expect(within(screen.getByTestId("account-purchases")).getByText("No purchases yet")).toBeInTheDocument();
212
- });
213
129
  });
214
130
 
215
- describe("AccountPage — empty states", () => {
216
- it("renders both empty states with no configured keys and no checkouts", async () => {
217
- mockExperimentKeys = [];
218
- mockCheckoutsMe.mockResolvedValue({ ok: true, body: [] });
219
- await renderPage();
220
-
221
- expect(mockSignupMe).not.toHaveBeenCalled();
222
- expect(within(screen.getByTestId("account-results")).getByText("No results yet")).toBeInTheDocument();
223
- expect(within(screen.getByTestId("account-purchases")).getByText("No purchases yet")).toBeInTheDocument();
224
- });
131
+ afterAll(() => {
132
+ accountExperimentKeys.length = 0;
225
133
  });
226
134
 
227
- describe("AccountPage — profile", () => {
228
- it("shows name, email, and the verified badge", async () => {
135
+ describe("the Inbox — D6a tabs (AC-7)", () => {
136
+ it("renders Recents · Suggested · Scheduled · All with counts, Recents selected", async () => {
137
+ accountExperimentKeys.push("ailk-founder-diagnostic");
229
138
  await renderPage();
230
- const profile = screen.getByTestId("account-profile");
231
- expect(within(profile).getByTestId("account-profile-name")).toHaveTextContent("Ada");
232
- expect(within(profile).getByTestId("account-profile-email")).toHaveTextContent("ada@example.com");
233
- expect(within(profile).getByTestId("account-email-status")).toHaveTextContent("Email verified");
234
- });
235
-
236
- it("shows the unverified badge for an unverified session", async () => {
237
- mockGetSession.mockResolvedValue({
238
- user: { ...SESSION.user, emailVerified: false },
239
- });
139
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Inbox");
140
+ const tabs = screen.getAllByRole("tab");
141
+ expect(tabs.map((tab) => tab.getAttribute("data-testid"))).toEqual([
142
+ "inbox-tab-recents",
143
+ "inbox-tab-suggested",
144
+ "inbox-tab-scheduled",
145
+ "inbox-tab-all",
146
+ ]);
147
+ expect(tabs.map((tab) => tab.textContent)).toEqual([
148
+ "Recents2",
149
+ "Suggested0",
150
+ "Scheduled0",
151
+ "All2",
152
+ ]);
153
+ expect(screen.getByTestId("inbox-tab-recents")).toHaveAttribute("aria-selected", "true");
154
+ });
155
+
156
+ it("result and purchase rows sit under Recents, newest first, each linking to its item page", async () => {
157
+ accountExperimentKeys.push("ailk-founder-diagnostic");
240
158
  await renderPage();
241
- expect(within(screen.getByTestId("account-profile")).getByTestId("account-email-status")).toHaveTextContent(
242
- "Email not verified",
159
+ const recents = screen.getByTestId("inbox-recents");
160
+ const rows = within(recents).getAllByTestId("inbox-recents-row");
161
+ expect(rows).toHaveLength(2);
162
+ // The purchase (Sep 4) is newer than the result (Sep 3).
163
+ expect(rows[0]).toHaveTextContent("pro");
164
+ expect(rows[1]).toHaveTextContent("ailk-founder-diagnostic");
165
+ expect(within(recents).getByText("pro").closest("a")).toHaveAttribute(
166
+ "href",
167
+ "/account/inbox/purchase/fc_done",
243
168
  );
169
+ expect(
170
+ within(recents).getByText("ailk-founder-diagnostic").closest("a"),
171
+ ).toHaveAttribute("href", "/account/inbox/result/ailk-founder-diagnostic");
244
172
  });
245
- });
246
173
 
247
- describe("AccountPage — sidebar nav", () => {
248
- it("renders the three in-page anchors with their labels", async () => {
249
- await renderPage();
250
- // AppShell may mount the rail twice (desktop rail + drawer) — assert on
251
- // the first instance of each.
252
- expect(screen.getAllByTestId("sidebar-nav-item-profile")[0]).toHaveTextContent("Profile");
253
- expect(screen.getAllByTestId("sidebar-nav-item-results")[0]).toHaveTextContent("Results");
254
- expect(screen.getAllByTestId("sidebar-nav-item-purchases")[0]).toHaveTextContent("Purchases");
255
- expect(screen.getAllByTestId("sidebar-nav-item-results")[0]).toHaveAttribute(
174
+ it("the same rows are in All; a non-default locale prefixes the item href", async () => {
175
+ await renderPage("fr");
176
+ fireEvent.mouseDown(screen.getByTestId("inbox-tab-all"));
177
+ const all = screen.getByTestId("inbox-all");
178
+ expect(within(all).getByText("pro").closest("a")).toHaveAttribute(
256
179
  "href",
257
- "/account#results",
180
+ "/fr/account/inbox/purchase/fc_done",
258
181
  );
259
182
  });
260
- });
261
183
 
262
- describe("AccountPage — sign out", () => {
263
- it("calls signOut, then pushes home and refreshes", async () => {
184
+ it("with no rows, every tab still renders and Recents shows the empty line", async () => {
185
+ mockCheckoutsMe.mockResolvedValue({ ok: true, body: [] });
264
186
  await renderPage();
265
- fireEvent.click(screen.getByTestId("account-sign-out"));
266
-
267
- await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
268
- expect(mockSignOut).toHaveBeenCalledTimes(1);
269
- expect(mockRefresh).toHaveBeenCalledTimes(1);
270
- expect(mockSignOut.mock.invocationCallOrder[0]).toBeLessThan(mockPush.mock.invocationCallOrder[0]!);
187
+ expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
188
+ "Recents0",
189
+ "Suggested0",
190
+ "Scheduled0",
191
+ "All0",
192
+ ]);
193
+ expect(screen.getByTestId("inbox-empty")).toHaveTextContent("Nothing here yet.");
271
194
  });
272
195
  });
273
196
 
274
- describe("AccountPage — subscription status banner (#6038)", () => {
275
- it("renders the banner for a subscriber with actionRequired: true", async () => {
197
+ describe("For-you items, not Inbox rows (AC-7, D9 as amended)", () => {
198
+ it("email-unverified and payment-due are For-you items in the phone stack, never Inbox rows", async () => {
199
+ mockGetSession.mockResolvedValue({
200
+ user: { ...SESSION.user, emailVerified: false },
201
+ });
276
202
  mockSubscriptionMe.mockResolvedValue({
277
203
  ok: true,
278
204
  body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: true },
279
205
  });
280
206
  await renderPage();
281
- expect(screen.getByTestId("subscription-status-banner")).toBeInTheDocument();
207
+ const forYou = screen.getByTestId("inbox-for-you");
208
+ expect(forYou).toHaveClass("md:hidden");
209
+ expect(within(forYou).getByTestId("for-you-stack-count")).toHaveTextContent("2");
210
+ const list = screen.getByTestId("inbox-list");
211
+ expect(within(list).queryByText("Email not verified")).not.toBeInTheDocument();
212
+ expect(
213
+ within(list).queryByText("Your payment didn't go through"),
214
+ ).not.toBeInTheDocument();
282
215
  });
283
216
 
284
- it("renders no banner for a healthy subscriber — AC-1", async () => {
285
- mockSubscriptionMe.mockResolvedValue({
286
- ok: true,
287
- body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: true },
288
- });
217
+ it("nothing for you → no For you section", async () => {
289
218
  await renderPage();
290
- expect(screen.queryByTestId("subscription-status-banner")).not.toBeInTheDocument();
219
+ expect(screen.queryByTestId("inbox-for-you")).not.toBeInTheDocument();
291
220
  });
292
221
 
293
- it("clears once the recovery lands — AC-3, no dismissal consulted", async () => {
294
- mockSubscriptionMe.mockResolvedValue({
295
- ok: true,
296
- body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: true },
297
- });
222
+ it("a member with no name sees 'Add your name' (needs-you, Open → User settings › General)", async () => {
223
+ mockGetSession.mockResolvedValue({ user: { ...SESSION.user, name: "" } });
298
224
  await renderPage();
299
- expect(screen.queryByTestId("subscription-status-banner")).not.toBeInTheDocument();
300
- });
301
-
302
- it.each([401, 403, 404])(
303
- "renders no banner and no throw on a %d read (AC-4, the existing SKIPPED_STATUSES constant)",
304
- async (status) => {
305
- mockSubscriptionMe.mockResolvedValue({
306
- ok: false,
307
- status,
308
- body: { error: { code: "n/a", message: "n/a" } },
309
- });
310
- await renderPage();
311
- expect(screen.getByTestId("account-page")).toBeInTheDocument();
312
- expect(screen.queryByTestId("subscription-status-banner")).not.toBeInTheDocument();
313
- },
314
- );
315
-
316
- it("rejects to the route error boundary on a 500 subscription read", async () => {
317
- mockSubscriptionMe.mockResolvedValue({
318
- ok: false,
319
- status: 500,
320
- body: { error: { code: "internal_error", message: "Boom" } },
321
- });
322
- await expect(Page({ params: Promise.resolve({ locale: "en" }) })).rejects.toThrow("Boom");
225
+ const forYou = screen.getByTestId("inbox-for-you");
226
+ const item = within(forYou).getByTestId("for-you-item");
227
+ expect(item).toHaveAttribute("data-item-id", "add-name");
228
+ expect(item).toHaveTextContent("Add your name");
229
+ // DESIGN.md §5.4c — the tag is icon + label.
230
+ expect(within(item).getByTestId("for-you-tag").querySelector("svg")).not.toBeNull();
231
+ expect(within(item).getByTestId("for-you-open")).toHaveAttribute(
232
+ "href",
233
+ "/account/settings#general",
234
+ );
323
235
  });
236
+ });
324
237
 
325
- it("renders the action slot when portalAvailable is true", async () => {
326
- mockSubscriptionMe.mockResolvedValue({
327
- ok: true,
328
- body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: true },
329
- });
238
+ describe("Hub unavailable (D12)", () => {
239
+ it("a failed read renders an error row with Retry, never an empty Inbox", async () => {
240
+ mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
330
241
  await renderPage();
331
- const banner = screen.getByTestId("subscription-status-banner");
332
- expect(within(banner).getByRole("button")).toBeInTheDocument();
242
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent(
243
+ "We couldn't load everything in your Inbox.",
244
+ );
245
+ expect(screen.queryByTestId("inbox-empty")).not.toBeInTheDocument();
246
+ fireEvent.click(screen.getByTestId("inbox-retry"));
247
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
333
248
  });
334
249
 
335
- it("renders no action slot when portalAvailable is false — D3.0a", async () => {
336
- mockSubscriptionMe.mockResolvedValue({
337
- ok: true,
338
- body: { actionRequired: true, status: "past_due", accessRevokesAt: null, portalAvailable: false },
339
- });
250
+ it("a failed For-you read also shows the error row", async () => {
251
+ mockSubscriptionMe.mockResolvedValue({ ok: false, status: 502, body: {} });
340
252
  await renderPage();
341
- const banner = screen.getByTestId("subscription-status-banner");
342
- expect(within(banner).queryByRole("button")).not.toBeInTheDocument();
253
+ expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
343
254
  });
344
255
  });