create-ailk 0.11.2 → 0.11.4

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 (76) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/surfaces.d.ts +4 -5
  4. package/dist/surfaces.js +50 -8
  5. package/package.json +3 -3
  6. package/templates/.env.example +29 -0
  7. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  8. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  9. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  10. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  11. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  12. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  13. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  14. package/templates/apps/api/src/server.ts +28 -0
  15. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  16. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  17. package/templates/apps/api/src/vercel-handler.ts +58 -12
  18. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  19. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  20. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  21. package/templates/apps/web/.env.example +4 -0
  22. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  23. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  24. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  25. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  26. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  27. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  28. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  29. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  30. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  31. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  32. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
  35. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  36. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  37. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  38. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
  39. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  40. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  43. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
  45. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  46. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  47. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  49. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  50. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  51. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  52. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  53. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  54. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  55. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  56. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  57. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  58. package/templates/apps/web/app/[locale]/layout.tsx +41 -28
  59. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  60. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  61. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  62. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  63. package/templates/apps/web/auth.ts +29 -5
  64. package/templates/apps/web/jest.config.cjs +5 -0
  65. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  66. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  67. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  68. package/templates/apps/web/lib/account-config.ts +219 -11
  69. package/templates/apps/web/lib/auth-email.ts +104 -2
  70. package/templates/apps/web/lib/auth-env.ts +37 -5
  71. package/templates/apps/web/lib/auth-routes.ts +33 -4
  72. package/templates/apps/web/messages/en.json +148 -21
  73. package/templates/database/CHANGELOG.md +14 -0
  74. package/templates/database/package.json +1 -1
  75. package/versions.json +33 -33
  76. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Tests for lib/account-config.ts — the base account area's contributions
3
+ * (#7429, spec 7423 U3 · AC-6; amended by #7448, U5 · D6a/D8a/D9).
4
+ *
5
+ * Covers:
6
+ * 1. The three contribution arrays are exported; the base area ships no
7
+ * Settings section (modules add those).
8
+ * 2. The base Inbox kinds are exactly `result` and `purchase` — rows of
9
+ * the Recents and All tabs; none fills Scheduled (the old Upcoming).
10
+ * 3. `email-unverified` and `payment-due` are For-you sources — needs-you
11
+ * items with an Open action to User settings, never Inbox rows.
12
+ * 4. Each contribution maps its existing read: the session for
13
+ * `email-unverified`, `waitlistSignups.me` per configured key for
14
+ * `result`, `flowCheckouts.me` for `purchase`, and `subscription.me`'s
15
+ * own `actionRequired` for `payment-due`.
16
+ * 5. A 404 — or a 401/403 while the email is unverified — is "nothing for
17
+ * this caller" (`[]`); any other failed read rejects, a 401/403 for a
18
+ * verified email included, so the Inbox can show an error row (D12).
19
+ */
20
+
21
+ const mockSignupMe = jest.fn();
22
+ const mockCheckoutsMe = jest.fn();
23
+ const mockSubscriptionMe = jest.fn();
24
+ jest.mock("@working-theory/api-client", () => ({
25
+ __esModule: true,
26
+ waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
27
+ flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
28
+ subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
29
+ }));
30
+
31
+ import type {
32
+ AccountContext,
33
+ AccountForYouSource,
34
+ AccountInboxKind,
35
+ } from "@working-theory/templates/account";
36
+
37
+ import {
38
+ accountExperimentKeys,
39
+ accountForYouSources,
40
+ accountInboxKinds,
41
+ accountSettingsSections,
42
+ } from "../account-config";
43
+
44
+ const INIT = { headers: { cookie: "session=abc" } };
45
+
46
+ function ctx(overrides: Partial<AccountContext["user"]> = {}): AccountContext {
47
+ return {
48
+ locale: "en",
49
+ user: {
50
+ name: "Ada",
51
+ email: "ada@example.com",
52
+ emailVerified: true,
53
+ ...overrides,
54
+ },
55
+ t: (key) => `t:${key}`,
56
+ init: INIT,
57
+ };
58
+ }
59
+
60
+ function kind(id: string): AccountInboxKind {
61
+ const found = accountInboxKinds.find((k) => k.kind === id);
62
+ if (!found) throw new Error(`no base kind ${id}`);
63
+ return found;
64
+ }
65
+
66
+ function source(id: string): AccountForYouSource {
67
+ const found = accountForYouSources.find((s) => s.id === id);
68
+ if (!found) throw new Error(`no base For-you source ${id}`);
69
+ return found;
70
+ }
71
+
72
+ const SIGNUP = {
73
+ experimentKey: "diag",
74
+ waitlistKey: "early",
75
+ completedAt: "2026-09-01T10:00:00.000Z",
76
+ tier: "gold",
77
+ score: 42,
78
+ aeoScore: null,
79
+ answers: { q3: null },
80
+ };
81
+
82
+ const CHECKOUT = {
83
+ id: "flchk_1",
84
+ flowSlug: "buy",
85
+ experimentKey: "diag",
86
+ offerKey: "pro",
87
+ status: "completed",
88
+ amountMinor: 4900,
89
+ currency: "USD",
90
+ period: "month",
91
+ completedAt: "2026-09-02T10:00:00.000Z",
92
+ createdAt: "2026-09-02T09:00:00.000Z",
93
+ };
94
+
95
+ const ok = <T>(body: T) => ({ ok: true as const, body });
96
+ const fail = (status: number) => ({ ok: false as const, status, body: null });
97
+
98
+ afterEach(() => {
99
+ jest.clearAllMocks();
100
+ accountExperimentKeys.length = 0;
101
+ });
102
+
103
+ describe("account-config exports (AC-6)", () => {
104
+ it("exports the three contribution arrays", () => {
105
+ expect(Array.isArray(accountInboxKinds)).toBe(true);
106
+ expect(accountSettingsSections).toEqual([]);
107
+ expect(Array.isArray(accountForYouSources)).toBe(true);
108
+ });
109
+
110
+ it("ships exactly the two base Inbox kinds, both Recents rows (D6a, D9)", () => {
111
+ expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
112
+ ["result", "updates"],
113
+ ["purchase", "updates"],
114
+ ]);
115
+ // Nothing in the base area fills Scheduled (the old Upcoming), and no
116
+ // Inbox row asks for a decision — that is the For-you panel's job.
117
+ expect(accountInboxKinds.some((k) => k.section === "upcoming")).toBe(false);
118
+ expect(accountInboxKinds.some((k) => k.section === "needs-you")).toBe(false);
119
+ });
120
+
121
+ it("ships email-unverified and payment-due as For-you sources, not Inbox kinds (D9 as amended)", () => {
122
+ expect(accountForYouSources.map((s) => s.id)).toEqual([
123
+ "email-unverified",
124
+ "payment-due",
125
+ ]);
126
+ const kinds = accountInboxKinds.map((k) => k.kind);
127
+ expect(kinds).not.toContain("email-unverified");
128
+ expect(kinds).not.toContain("payment-due");
129
+ });
130
+
131
+ it("gives every kind a unique id — the `[kind]` route segment", () => {
132
+ const ids = accountInboxKinds.map((k) => k.kind);
133
+ expect(new Set(ids).size).toBe(ids.length);
134
+ });
135
+
136
+ it("names every kind by a message key, never a literal", () => {
137
+ for (const k of accountInboxKinds)
138
+ expect(k.label).toMatch(/^[a-z]+(\.[A-Za-z]+)+$/);
139
+ });
140
+ });
141
+
142
+ describe("email-unverified — a For-you item from the session", () => {
143
+ it("is one needs-you item, opening User settings › General", async () => {
144
+ const items = await source("email-unverified").load(
145
+ ctx({ emailVerified: false }),
146
+ );
147
+ expect(items).toHaveLength(1);
148
+ expect(items[0]).toMatchObject({
149
+ id: "email-unverified",
150
+ type: { id: "email", label: "t:profile.email", tone: "needs-you" },
151
+ title: "t:profile.unverified",
152
+ action: { kind: "open", href: "/account/settings#general" },
153
+ dismissible: true,
154
+ });
155
+ });
156
+
157
+ it("is empty when the email is verified, and reads no API", async () => {
158
+ await expect(source("email-unverified").load(ctx())).resolves.toEqual([]);
159
+ expect(mockSignupMe).not.toHaveBeenCalled();
160
+ expect(mockCheckoutsMe).not.toHaveBeenCalled();
161
+ expect(mockSubscriptionMe).not.toHaveBeenCalled();
162
+ });
163
+ });
164
+
165
+ describe("result — waitlistSignups.me per accountExperimentKeys", () => {
166
+ it("reads each configured key with the forwarded init, skipping a 404", async () => {
167
+ accountExperimentKeys.push("diag", "other");
168
+ mockSignupMe.mockImplementation(async (key: string) =>
169
+ key === "diag" ? ok(SIGNUP) : fail(404),
170
+ );
171
+ const items = await kind("result").load(ctx());
172
+ expect(mockSignupMe).toHaveBeenCalledWith("diag", INIT);
173
+ expect(mockSignupMe).toHaveBeenCalledWith("other", INIT);
174
+ expect(items).toEqual([
175
+ { id: "diag", title: "diag", at: SIGNUP.completedAt, data: SIGNUP },
176
+ ]);
177
+ });
178
+
179
+ it("is empty with no configured key", async () => {
180
+ await expect(kind("result").load(ctx())).resolves.toEqual([]);
181
+ expect(mockSignupMe).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it("rejects on a failed read", async () => {
185
+ accountExperimentKeys.push("diag");
186
+ mockSignupMe.mockResolvedValue(fail(500));
187
+ await expect(kind("result").load(ctx())).rejects.toThrow();
188
+ });
189
+ });
190
+
191
+ describe("purchase — flowCheckouts.me", () => {
192
+ it("is one item per checkout, at its completion time", async () => {
193
+ mockCheckoutsMe.mockResolvedValue(ok([CHECKOUT]));
194
+ const items = await kind("purchase").load(ctx());
195
+ expect(mockCheckoutsMe).toHaveBeenCalledWith(INIT);
196
+ expect(items).toEqual([
197
+ { id: "flchk_1", title: "pro", at: CHECKOUT.completedAt, data: CHECKOUT },
198
+ ]);
199
+ });
200
+
201
+ it("is empty on a 404", async () => {
202
+ mockCheckoutsMe.mockResolvedValue(fail(404));
203
+ await expect(kind("purchase").load(ctx())).resolves.toEqual([]);
204
+ });
205
+
206
+ it.each([401, 403])(
207
+ "is empty on a %i while the email is unverified",
208
+ async (status) => {
209
+ mockCheckoutsMe.mockResolvedValue(fail(status));
210
+ await expect(
211
+ kind("purchase").load(ctx({ emailVerified: false })),
212
+ ).resolves.toEqual([]);
213
+ },
214
+ );
215
+
216
+ it.each([401, 403])(
217
+ "rejects on a %i for a verified email",
218
+ async (status) => {
219
+ mockCheckoutsMe.mockResolvedValue(fail(status));
220
+ await expect(kind("purchase").load(ctx())).rejects.toMatchObject({
221
+ status,
222
+ });
223
+ },
224
+ );
225
+
226
+ it("rejects on a failed read", async () => {
227
+ mockCheckoutsMe.mockResolvedValue(fail(503));
228
+ await expect(kind("purchase").load(ctx())).rejects.toThrow();
229
+ });
230
+
231
+ it("details the checkout's fields", () => {
232
+ const item = {
233
+ id: "flchk_1",
234
+ title: "pro",
235
+ at: CHECKOUT.completedAt,
236
+ data: CHECKOUT,
237
+ };
238
+ const detail = kind("purchase").renderDetail(item, ctx());
239
+ expect(detail.status).toBe("completed");
240
+ expect(detail.fields?.items.map((i) => i.label)).toEqual([
241
+ "t:purchases.offer",
242
+ "t:purchases.amount",
243
+ "t:purchases.period",
244
+ "t:purchases.date",
245
+ ]);
246
+ });
247
+ });
248
+
249
+ describe("payment-due — a For-you item from subscription.me's actionRequired", () => {
250
+ const SUB = {
251
+ actionRequired: true,
252
+ status: "past_due",
253
+ accessRevokesAt: null,
254
+ portalAvailable: true,
255
+ };
256
+
257
+ it("is one needs-you item, opening User settings › Billing, when the API says action is required", async () => {
258
+ mockSubscriptionMe.mockResolvedValue(ok(SUB));
259
+ const items = await source("payment-due").load(ctx());
260
+ expect(mockSubscriptionMe).toHaveBeenCalledWith(INIT);
261
+ expect(items).toHaveLength(1);
262
+ expect(items[0]).toMatchObject({
263
+ id: "payment-due",
264
+ type: { id: "payment", label: "t:subscription.action", tone: "needs-you" },
265
+ title: "t:subscription.headline",
266
+ action: { kind: "open", href: "/account/settings#billing" },
267
+ dismissible: true,
268
+ });
269
+ });
270
+
271
+ it("is empty when it is not — the status is never re-derived", async () => {
272
+ mockSubscriptionMe.mockResolvedValue(ok({ ...SUB, actionRequired: false }));
273
+ await expect(source("payment-due").load(ctx())).resolves.toEqual([]);
274
+ });
275
+
276
+ it("is empty on a 404 (the commerce-disabled build)", async () => {
277
+ mockSubscriptionMe.mockResolvedValue(fail(404));
278
+ await expect(source("payment-due").load(ctx())).resolves.toEqual([]);
279
+ });
280
+
281
+ it("rejects on a failed read", async () => {
282
+ mockSubscriptionMe.mockResolvedValue(fail(500));
283
+ await expect(source("payment-due").load(ctx())).rejects.toThrow();
284
+ });
285
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file auth-email.test.ts — Better Auth's three mail callbacks (#5962 § D3).
2
+ * @file auth-email.test.ts — Better Auth's mail callbacks (#5962 § D3, #7435).
3
3
  *
4
4
  * Written before the implementation. The transport is INJECTED, so nothing
5
5
  * here mocks an internal module and nothing here sends mail.
@@ -23,6 +23,8 @@
23
23
  import type { AuthEmailLog } from "../auth-email";
24
24
  import {
25
25
  buildAuthEmailRequest,
26
+ MAGIC_LINK_TTL_SECONDS,
27
+ makeMagicLinkOptions,
26
28
  makeSendResetPassword,
27
29
  makeSendVerificationEmail,
28
30
  makeSendPasswordChangedNotice,
@@ -212,3 +214,156 @@ describe("makeSendPasswordChangedNotice (§ D17)", () => {
212
214
  ).resolves.toBeUndefined();
213
215
  });
214
216
  });
217
+
218
+ // ── #7435 — the magic-link callback (§ D2, § D3, AC-2) ─────────────────────
219
+
220
+ /**
221
+ * A Better Auth endpoint-context double: only the two members the callback
222
+ * may touch. `runInBackgroundOrAwait` records the promise it is handed and
223
+ * returns immediately, as the real one does with `backgroundTasks.handler`.
224
+ */
225
+ function makeCtx(user: { id: string; email: string } | null | "throws") {
226
+ const background: Promise<unknown>[] = [];
227
+ const findUserByEmail = jest.fn((_email: string) =>
228
+ user === "throws"
229
+ ? Promise.reject(new Error("db down for person@example.com"))
230
+ : Promise.resolve(user ? { user } : null),
231
+ );
232
+ const runInBackgroundOrAwait = jest.fn((p: Promise<unknown> | void) => {
233
+ if (p) background.push(p);
234
+ });
235
+ return {
236
+ background,
237
+ findUserByEmail,
238
+ runInBackgroundOrAwait,
239
+ ctx: { context: { internalAdapter: { findUserByEmail }, runInBackgroundOrAwait } },
240
+ };
241
+ }
242
+
243
+ const LINK =
244
+ "https://site.example/api/auth/magic-link/verify?token=MLTOKEN&callbackURL=%2Faccount";
245
+
246
+ describe("makeMagicLinkOptions (#7435 § D2)", () => {
247
+ it("is sign-in only, five minutes, hashed at rest", () => {
248
+ const opts = makeMagicLinkOptions({ send: jest.fn() });
249
+ expect(MAGIC_LINK_TTL_SECONDS).toBe(300);
250
+ expect(opts).toMatchObject({
251
+ disableSignUp: true,
252
+ expiresIn: 300,
253
+ storeToken: "hashed",
254
+ });
255
+ expect(typeof opts.sendMagicLink).toBe("function");
256
+ // allowedAttempts is deliberately unset — the plugin warns on any value.
257
+ expect(opts).not.toHaveProperty("allowedAttempts");
258
+ });
259
+ });
260
+
261
+ describe("sendMagicLink — existing users only, deferred (#7435 § D3, AC-2)", () => {
262
+ it("sends NOTHING for an unknown address, never backgrounds, and logs no address", async () => {
263
+ const send = jest.fn();
264
+ const { lines, log } = makeLog();
265
+ const { ctx, findUserByEmail, runInBackgroundOrAwait } = makeCtx(null);
266
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
267
+
268
+ await expect(
269
+ sendMagicLink({ email: "nobody@example.com", url: LINK, token: "MLTOKEN" }, ctx),
270
+ ).resolves.toBeUndefined();
271
+
272
+ expect(findUserByEmail).toHaveBeenCalledWith("nobody@example.com");
273
+ expect(runInBackgroundOrAwait).not.toHaveBeenCalled();
274
+ expect(send).not.toHaveBeenCalled();
275
+ expect(lines).toEqual([
276
+ { level: "info", fields: { kind: "magic-link", reason: "no_account" } },
277
+ ]);
278
+ const serialized = JSON.stringify(lines);
279
+ expect(serialized).not.toContain("nobody@example.com");
280
+ expect(serialized).not.toContain("MLTOKEN");
281
+ });
282
+
283
+ it("backgrounds ONE magic-link send for a known address and returns before it settles", async () => {
284
+ let settle!: (v: unknown) => void;
285
+ const send = jest.fn(
286
+ (_rawBody: string, _headers: Record<string, string>) =>
287
+ new Promise((resolve) => (settle = resolve)),
288
+ );
289
+ const { lines, log } = makeLog();
290
+ const { ctx, background, runInBackgroundOrAwait } = makeCtx({
291
+ id: "user_1",
292
+ email: "person@example.com",
293
+ });
294
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
295
+
296
+ await sendMagicLink(
297
+ { email: "Person@Example.com", url: LINK, token: "MLTOKEN" },
298
+ ctx,
299
+ );
300
+
301
+ // Returned while the transport promise is still pending: the endpoint's
302
+ // answer does not wait on the relay round-trip (no timing oracle).
303
+ expect(runInBackgroundOrAwait).toHaveBeenCalledTimes(1);
304
+ expect(lines.some((l) => l.fields.reason === "sent")).toBe(false);
305
+
306
+ expect(send).toHaveBeenCalledTimes(1);
307
+ const [rawBody] = send.mock.calls[0]!;
308
+ expect(JSON.parse(rawBody)).toEqual({
309
+ kind: "magic-link",
310
+ userId: "user_1",
311
+ // The STORED address, not the typed casing.
312
+ to: "person@example.com",
313
+ url: LINK,
314
+ });
315
+
316
+ settle({ ok: true });
317
+ await Promise.all(background);
318
+ expect(lines).toEqual([
319
+ {
320
+ level: "info",
321
+ fields: { userId: "user_1", kind: "magic-link", reason: "sent" },
322
+ },
323
+ ]);
324
+ const serialized = JSON.stringify(lines);
325
+ expect(serialized).not.toContain("person@example.com");
326
+ expect(serialized).not.toContain("MLTOKEN");
327
+ expect(serialized).not.toContain("magic-link/verify");
328
+ });
329
+
330
+ it("never throws, and sends nothing, when the user lookup fails", async () => {
331
+ const send = jest.fn();
332
+ const { lines, log } = makeLog();
333
+ const { ctx, runInBackgroundOrAwait } = makeCtx("throws");
334
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
335
+
336
+ await expect(
337
+ sendMagicLink({ email: "person@example.com", url: LINK, token: "MLTOKEN" }, ctx),
338
+ ).resolves.toBeUndefined();
339
+
340
+ expect(runInBackgroundOrAwait).not.toHaveBeenCalled();
341
+ expect(send).not.toHaveBeenCalled();
342
+ expect(lines).toEqual([
343
+ {
344
+ level: "warn",
345
+ fields: {
346
+ kind: "magic-link",
347
+ reason: "send_failed",
348
+ errorKind: "lookup_failed",
349
+ },
350
+ },
351
+ ]);
352
+ });
353
+
354
+ it("never throws, and sends nothing, when Better Auth passes no context", async () => {
355
+ const send = jest.fn();
356
+ const { lines, log } = makeLog();
357
+ const { sendMagicLink } = makeMagicLinkOptions({ send, log });
358
+
359
+ await expect(
360
+ sendMagicLink({ email: "person@example.com", url: LINK, token: "MLTOKEN" }),
361
+ ).resolves.toBeUndefined();
362
+ expect(send).not.toHaveBeenCalled();
363
+ expect(lines[0]?.fields).toEqual({
364
+ kind: "magic-link",
365
+ reason: "send_failed",
366
+ errorKind: "lookup_failed",
367
+ });
368
+ });
369
+ });
@@ -4,10 +4,10 @@
4
4
  * `isAuthPath()` is the ONE list the locale layout branches on and the five
5
5
  * `(auth)` page segments are named from, so the layout and the routes cannot
6
6
  * drift. Covers bare paths, locale-prefixed paths, near misses, nested paths,
7
- * and the absent-header fail-safe.
7
+ * and the absent-header fail-safe — and the same for `isAccountPath()` (#7430).
8
8
  */
9
9
 
10
- import { AUTH_SEGMENTS, isAuthPath } from "../auth-routes";
10
+ import { AUTH_SEGMENTS, isAccountPath, isAuthPath } from "../auth-routes";
11
11
 
12
12
  const LOCALES = ["en", "fr", "de"];
13
13
 
@@ -72,3 +72,42 @@ describe("isAuthPath", () => {
72
72
  expect(isAuthPath(undefined, LOCALES)).toBe(false);
73
73
  });
74
74
  });
75
+
76
+ describe("isAccountPath (#7430, spec 7423 D4)", () => {
77
+ it("matches the account root, bare and locale-prefixed", () => {
78
+ expect(isAccountPath("/account", LOCALES)).toBe(true);
79
+ expect(isAccountPath("/en/account", LOCALES)).toBe(true);
80
+ expect(isAccountPath("/fr/account", LOCALES)).toBe(true);
81
+ });
82
+
83
+ it("matches every path under /account", () => {
84
+ expect(isAccountPath("/account/settings", LOCALES)).toBe(true);
85
+ expect(isAccountPath("/account/support", LOCALES)).toBe(true);
86
+ expect(isAccountPath("/fr/account/inbox/purchase/fc_1", LOCALES)).toBe(true);
87
+ });
88
+
89
+ it("matches a trailing slash and a query string", () => {
90
+ expect(isAccountPath("/account/", LOCALES)).toBe(true);
91
+ expect(isAccountPath("/account?tab=all", LOCALES)).toBe(true);
92
+ });
93
+
94
+ it("does not match a near miss or a marketing path", () => {
95
+ expect(isAccountPath("/accounts", LOCALES)).toBe(false);
96
+ expect(isAccountPath("/about", LOCALES)).toBe(false);
97
+ expect(isAccountPath("/blog/account", LOCALES)).toBe(false);
98
+ });
99
+
100
+ it("does not match an auth path", () => {
101
+ expect(isAccountPath("/sign-in?next=/account", LOCALES)).toBe(false);
102
+ });
103
+
104
+ it("does not strip a prefix that is not an enabled locale", () => {
105
+ expect(isAccountPath("/es/account", LOCALES)).toBe(false);
106
+ });
107
+
108
+ it("fails to NOT-an-account-path for an empty or absent pathname", () => {
109
+ expect(isAccountPath("", LOCALES)).toBe(false);
110
+ expect(isAccountPath(undefined, LOCALES)).toBe(false);
111
+ expect(isAccountPath(null, LOCALES)).toBe(false);
112
+ });
113
+ });