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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +41 -28
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +29 -5
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +148 -21
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /[locale]/account/settings — User settings (#7430, spec 7423
|
|
3
|
+
* U4 · D10, D10a; AC-8, plus the operator fold-ins of 2026-10-01).
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* 1. General, Security, Notifications and Privacy always render; no
|
|
7
|
+
* Credentials, Scope, Organizations or Credits section exists.
|
|
8
|
+
* 2. Billing renders for `subscription.me()` answering `status: "active"`
|
|
9
|
+
* or `portalAvailable: true`; it is absent for `status: null` with
|
|
10
|
+
* `portalAvailable: false`, and for a 404.
|
|
11
|
+
* 3. General lets a member SET and edit their name (an accepted applicant
|
|
12
|
+
* arrives with none, #7418) — saved through Better Auth's `updateUser`.
|
|
13
|
+
* 4. The color-mode control renders only when the site's theme defines
|
|
14
|
+
* more than one mode.
|
|
15
|
+
* 5. Security lists the configured sign-in methods; a Password row only
|
|
16
|
+
* when the password method is on.
|
|
17
|
+
* 6. Privacy links to the cookie settings and files a deletion request.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { act, 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 mockSubscriptionMe = jest.fn();
|
|
62
|
+
const mockLeadsSubmit = jest.fn();
|
|
63
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
64
|
+
__esModule: true,
|
|
65
|
+
waitlistSignups: { me: jest.fn() },
|
|
66
|
+
flowCheckouts: { me: jest.fn() },
|
|
67
|
+
subscription: {
|
|
68
|
+
me: (...args: unknown[]) => mockSubscriptionMe(...args),
|
|
69
|
+
portalSession: jest.fn(),
|
|
70
|
+
},
|
|
71
|
+
leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
|
|
72
|
+
visitorForwardHeaders: () => null,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const mockUpdateUser = jest.fn();
|
|
76
|
+
jest.mock("better-auth/react", () => ({
|
|
77
|
+
__esModule: true,
|
|
78
|
+
createAuthClient: () => ({
|
|
79
|
+
signOut: jest.fn(),
|
|
80
|
+
updateUser: (...args: unknown[]) => mockUpdateUser(...args),
|
|
81
|
+
}),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
const mockRefresh = jest.fn();
|
|
85
|
+
jest.mock("next/navigation", () => ({
|
|
86
|
+
__esModule: true,
|
|
87
|
+
useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
|
|
88
|
+
usePathname: () => "/account/settings",
|
|
89
|
+
redirect: jest.fn(),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
import Page from "../settings/page";
|
|
93
|
+
|
|
94
|
+
const SESSION = {
|
|
95
|
+
user: {
|
|
96
|
+
id: "user_1",
|
|
97
|
+
name: "Ada Lovelace",
|
|
98
|
+
email: "ada@example.com",
|
|
99
|
+
emailVerified: true,
|
|
100
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const subscriptionBody = (body: Record<string, unknown>) => ({
|
|
105
|
+
ok: true,
|
|
106
|
+
body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false, ...body },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── The site's theme, as `_site.mdx` declares it ──────────────────────────────
|
|
110
|
+
|
|
111
|
+
let contentRoot: string;
|
|
112
|
+
|
|
113
|
+
function writeSite(frontmatter: string): void {
|
|
114
|
+
writeFileSync(join(contentRoot, "_site.mdx"), `---\n${frontmatter}\n---\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ENV_KEYS = [
|
|
118
|
+
"AILK_CONTENT_ROOT",
|
|
119
|
+
"GOOGLE_CLIENT_ID",
|
|
120
|
+
"GOOGLE_CLIENT_SECRET",
|
|
121
|
+
"GITHUB_CLIENT_ID",
|
|
122
|
+
"GITHUB_CLIENT_SECRET",
|
|
123
|
+
"AUTH_EMAIL_SERVER",
|
|
124
|
+
"AUTH_EMAIL_FROM",
|
|
125
|
+
"AUTH_EMAIL_WEBHOOK_SECRET",
|
|
126
|
+
"BETTER_AUTH_URL",
|
|
127
|
+
"BETTER_AUTH_SECRET",
|
|
128
|
+
] as const;
|
|
129
|
+
const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string>> = {};
|
|
130
|
+
|
|
131
|
+
beforeAll(() => {
|
|
132
|
+
for (const key of ENV_KEYS) {
|
|
133
|
+
const value = process.env[key];
|
|
134
|
+
if (value !== undefined) savedEnv[key] = value;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterAll(() => {
|
|
139
|
+
for (const key of ENV_KEYS) {
|
|
140
|
+
const value = savedEnv[key];
|
|
141
|
+
if (value === undefined) delete process.env[key];
|
|
142
|
+
else process.env[key] = value;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
for (const key of ENV_KEYS) delete process.env[key];
|
|
148
|
+
contentRoot = mkdtempSync(join(tmpdir(), "ailk-settings-"));
|
|
149
|
+
process.env.AILK_CONTENT_ROOT = contentRoot;
|
|
150
|
+
writeSite('siteName: "Test"');
|
|
151
|
+
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
152
|
+
mockSubscriptionMe.mockReset().mockResolvedValue(subscriptionBody({}));
|
|
153
|
+
mockLeadsSubmit.mockReset().mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
154
|
+
mockUpdateUser.mockReset().mockResolvedValue({ data: { status: true }, error: null });
|
|
155
|
+
mockRefresh.mockReset();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
rmSync(contentRoot, { recursive: true, force: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
async function renderPage(): Promise<void> {
|
|
163
|
+
const element = await Page({ params: Promise.resolve({ locale: "en" }) });
|
|
164
|
+
render(element);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sectionTitles(): string[] {
|
|
168
|
+
return screen
|
|
169
|
+
.getAllByRole("heading", { level: 2 })
|
|
170
|
+
.map((heading) => heading.textContent ?? "");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe("the base sections (AC-8)", () => {
|
|
174
|
+
it("renders General, Security, Notifications and Privacy — and no admin-only section", async () => {
|
|
175
|
+
await renderPage();
|
|
176
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Settings");
|
|
177
|
+
expect(sectionTitles()).toEqual(["General", "Security", "Notifications", "Privacy"]);
|
|
178
|
+
for (const absent of [/credentials/i, /scope/i, /organizations/i, /credits/i]) {
|
|
179
|
+
expect(screen.queryByRole("heading", { name: absent })).not.toBeInTheDocument();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("each section is an anchor the For-you items link to", async () => {
|
|
184
|
+
await renderPage();
|
|
185
|
+
for (const id of ["general", "security", "notifications", "privacy"]) {
|
|
186
|
+
expect(document.getElementById(id)).toBeInTheDocument();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("Billing (AC-8, D10)", () => {
|
|
192
|
+
it('renders for status: "active", with the status', async () => {
|
|
193
|
+
mockSubscriptionMe.mockResolvedValue(subscriptionBody({ status: "active" }));
|
|
194
|
+
await renderPage();
|
|
195
|
+
const billing = document.getElementById("billing")!;
|
|
196
|
+
expect(within(billing).getByRole("heading", { level: 2 })).toHaveTextContent("Billing");
|
|
197
|
+
expect(billing).toHaveTextContent("Active");
|
|
198
|
+
expect(within(billing).queryByTestId("account-billing-manage")).not.toBeInTheDocument();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("renders for portalAvailable: true, with Manage billing", async () => {
|
|
202
|
+
mockSubscriptionMe.mockResolvedValue(subscriptionBody({ portalAvailable: true }));
|
|
203
|
+
await renderPage();
|
|
204
|
+
const billing = document.getElementById("billing")!;
|
|
205
|
+
expect(within(billing).getByTestId("account-billing-manage")).toHaveTextContent(
|
|
206
|
+
"Manage billing",
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("shows when access ends, when set", async () => {
|
|
211
|
+
mockSubscriptionMe.mockResolvedValue(
|
|
212
|
+
subscriptionBody({ status: "canceled", accessRevokesAt: "2026-10-20T00:00:00.000Z" }),
|
|
213
|
+
);
|
|
214
|
+
await renderPage();
|
|
215
|
+
const billing = document.getElementById("billing")!;
|
|
216
|
+
expect(billing).toHaveTextContent("Canceled");
|
|
217
|
+
expect(billing).toHaveTextContent("Access ends");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("is absent for status: null with portalAvailable: false", async () => {
|
|
221
|
+
await renderPage();
|
|
222
|
+
expect(document.getElementById("billing")).not.toBeInTheDocument();
|
|
223
|
+
expect(sectionTitles()).not.toContain("Billing");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("is absent for a 404 (the commerce-disabled build)", async () => {
|
|
227
|
+
mockSubscriptionMe.mockResolvedValue({ ok: false, status: 404, body: {} });
|
|
228
|
+
await renderPage();
|
|
229
|
+
expect(document.getElementById("billing")).not.toBeInTheDocument();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it.each([401, 403])("is absent for a %i", async (status) => {
|
|
233
|
+
mockSubscriptionMe.mockResolvedValue({ ok: false, status, body: {} });
|
|
234
|
+
await renderPage();
|
|
235
|
+
expect(document.getElementById("billing")).not.toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("General — the member's name (operator fold-in, #7418)", () => {
|
|
240
|
+
it("shows the email read-only and the name in an editable field", async () => {
|
|
241
|
+
await renderPage();
|
|
242
|
+
const general = document.getElementById("general")!;
|
|
243
|
+
expect(within(general).getByTestId("account-settings-email")).toHaveTextContent(
|
|
244
|
+
"ada@example.com",
|
|
245
|
+
);
|
|
246
|
+
expect(within(general).getByLabelText("Name")).toHaveValue("Ada Lovelace");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("a member with no name can set one: Save is disabled until the field changes, then saves through updateUser", async () => {
|
|
250
|
+
mockGetSession.mockResolvedValue({ user: { ...SESSION.user, name: "" } });
|
|
251
|
+
await renderPage();
|
|
252
|
+
const input = screen.getByLabelText("Name");
|
|
253
|
+
expect(input).toHaveValue("");
|
|
254
|
+
const save = screen.getByTestId("account-name-save");
|
|
255
|
+
expect(save).toBeDisabled();
|
|
256
|
+
fireEvent.change(input, { target: { value: " Grace Hopper " } });
|
|
257
|
+
expect(save).toBeEnabled();
|
|
258
|
+
expect(screen.getByTestId("account-name-status")).toHaveTextContent("Unsaved changes");
|
|
259
|
+
await act(async () => {
|
|
260
|
+
fireEvent.click(save);
|
|
261
|
+
});
|
|
262
|
+
expect(mockUpdateUser).toHaveBeenCalledWith({ name: "Grace Hopper" });
|
|
263
|
+
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
|
264
|
+
expect(screen.getByTestId("account-name-status")).toHaveTextContent("Saved.");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("a blank name is refused without a call", async () => {
|
|
268
|
+
await renderPage();
|
|
269
|
+
const input = screen.getByLabelText("Name");
|
|
270
|
+
fireEvent.change(input, { target: { value: " " } });
|
|
271
|
+
await act(async () => {
|
|
272
|
+
fireEvent.click(screen.getByTestId("account-name-save"));
|
|
273
|
+
});
|
|
274
|
+
expect(mockUpdateUser).not.toHaveBeenCalled();
|
|
275
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Enter your name.");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("a failed save says so and keeps the edit", async () => {
|
|
279
|
+
mockUpdateUser.mockResolvedValue({ data: null, error: { message: "nope" } });
|
|
280
|
+
await renderPage();
|
|
281
|
+
const input = screen.getByLabelText("Name");
|
|
282
|
+
fireEvent.change(input, { target: { value: "Ada King" } });
|
|
283
|
+
await act(async () => {
|
|
284
|
+
fireEvent.click(screen.getByTestId("account-name-save"));
|
|
285
|
+
});
|
|
286
|
+
expect(screen.getByRole("alert")).toHaveTextContent("We couldn't save your name.");
|
|
287
|
+
expect(input).toHaveValue("Ada King");
|
|
288
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("General — a save that throws", () => {
|
|
293
|
+
it("a network failure says so and re-enables Save", async () => {
|
|
294
|
+
mockUpdateUser.mockRejectedValue(new TypeError("Failed to fetch"));
|
|
295
|
+
await renderPage();
|
|
296
|
+
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
|
|
297
|
+
await act(async () => {
|
|
298
|
+
fireEvent.click(screen.getByTestId("account-name-save"));
|
|
299
|
+
});
|
|
300
|
+
expect(screen.getByRole("alert")).toHaveTextContent("We couldn't save your name.");
|
|
301
|
+
expect(screen.getByTestId("account-name-save")).toBeEnabled();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("General — color mode only on a multi-mode theme (operator fold-in)", () => {
|
|
306
|
+
it("a site with no theme block (the default light + dark palette) shows Light · Auto · Dark", async () => {
|
|
307
|
+
await renderPage();
|
|
308
|
+
const control = screen.getByTestId("account-color-mode");
|
|
309
|
+
expect(within(control).getAllByRole("radio").map((r) => r.textContent)).toEqual([
|
|
310
|
+
"Light",
|
|
311
|
+
"Auto",
|
|
312
|
+
"Dark",
|
|
313
|
+
]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("a theme declaring both modes shows the control", async () => {
|
|
317
|
+
writeSite('theme:\n colors:\n light:\n bg: "#ffffff"\n dark:\n bg: "#000000"');
|
|
318
|
+
await renderPage();
|
|
319
|
+
expect(screen.getByTestId("account-color-mode")).toBeInTheDocument();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("a light-only theme shows no control", async () => {
|
|
323
|
+
writeSite('theme:\n colors:\n light:\n bg: "#fdfcfa"\n text: "#1c1917"');
|
|
324
|
+
await renderPage();
|
|
325
|
+
expect(screen.queryByTestId("account-color-mode")).not.toBeInTheDocument();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("choosing Dark writes the theme cookie and the root's data-theme", async () => {
|
|
329
|
+
await renderPage();
|
|
330
|
+
const control = screen.getByTestId("account-color-mode");
|
|
331
|
+
fireEvent.click(within(control).getByRole("radio", { name: "Dark" }));
|
|
332
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
|
333
|
+
expect(document.cookie).toContain("ailk-theme=dark");
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("Security — the configured sign-in methods (D10)", () => {
|
|
338
|
+
it("lists each configured method; no Password row without the password method", async () => {
|
|
339
|
+
process.env.GOOGLE_CLIENT_ID = "id";
|
|
340
|
+
process.env.GOOGLE_CLIENT_SECRET = "secret";
|
|
341
|
+
await renderPage();
|
|
342
|
+
const security = document.getElementById("security")!;
|
|
343
|
+
expect(within(security).getByText("Google")).toBeInTheDocument();
|
|
344
|
+
expect(within(security).queryByText("GitHub")).not.toBeInTheDocument();
|
|
345
|
+
expect(within(security).queryByText("Password")).not.toBeInTheDocument();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("the password method adds a Password row", async () => {
|
|
349
|
+
process.env.AUTH_EMAIL_SERVER = "smtp://x";
|
|
350
|
+
process.env.AUTH_EMAIL_FROM = "site@example.com";
|
|
351
|
+
await renderPage();
|
|
352
|
+
const security = document.getElementById("security")!;
|
|
353
|
+
expect(within(security).getByText("Password")).toBeInTheDocument();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("no configured method says so", async () => {
|
|
357
|
+
await renderPage();
|
|
358
|
+
expect(document.getElementById("security")).toHaveTextContent(
|
|
359
|
+
"No sign-in method is configured.",
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("Privacy (D10)", () => {
|
|
365
|
+
it("links to the cookie settings", async () => {
|
|
366
|
+
await renderPage();
|
|
367
|
+
const privacy = document.getElementById("privacy")!;
|
|
368
|
+
expect(within(privacy).getByText("Cookie settings").closest("a")).toHaveAttribute(
|
|
369
|
+
"href",
|
|
370
|
+
"/privacy#cookie-settings",
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("Request account deletion files a support request of kind deletion", async () => {
|
|
375
|
+
await renderPage();
|
|
376
|
+
const privacy = document.getElementById("privacy")!;
|
|
377
|
+
await act(async () => {
|
|
378
|
+
fireEvent.click(within(privacy).getByTestId("account-deletion-request"));
|
|
379
|
+
});
|
|
380
|
+
await waitFor(() => expect(mockLeadsSubmit).toHaveBeenCalledTimes(1));
|
|
381
|
+
const [body] = mockLeadsSubmit.mock.calls[0] as [Record<string, unknown>];
|
|
382
|
+
expect(body).toEqual({
|
|
383
|
+
source: "web",
|
|
384
|
+
email: "ada@example.com",
|
|
385
|
+
payload: {
|
|
386
|
+
kind: "deletion",
|
|
387
|
+
subject: "Account deletion request",
|
|
388
|
+
message: "Please delete my account and its data.",
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
await waitFor(() =>
|
|
392
|
+
expect(within(privacy).getByRole("status")).toHaveTextContent(
|
|
393
|
+
"Your deletion request is on its way.",
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /[locale]/account/support — Contact support (#7430, spec 7423
|
|
3
|
+
* U4 · D11; AC-9).
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* 1. The form posts `{ kind: "support", subject, message }` to
|
|
7
|
+
* `/v1/leads/submit` (`leads.submit`) with `source: "web"`, the
|
|
8
|
+
* session's email and no `siteId`, and shows a confirmation on 201.
|
|
9
|
+
* 2. A missing subject or message is refused without a call.
|
|
10
|
+
* 3. A failed submit says so and keeps what was typed; the API's
|
|
11
|
+
* `lead_capture_not_configured` 503 says requests can't be sent.
|
|
12
|
+
*/
|
|
13
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
14
|
+
|
|
15
|
+
jest.mock("next-intl/server", () => {
|
|
16
|
+
const msgs: unknown = require("../../../../../messages/en.json");
|
|
17
|
+
const dig = (obj: unknown, path: string): unknown =>
|
|
18
|
+
path.split(".").reduce<unknown>(
|
|
19
|
+
(acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
|
|
20
|
+
obj,
|
|
21
|
+
);
|
|
22
|
+
return {
|
|
23
|
+
__esModule: true,
|
|
24
|
+
getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
|
|
25
|
+
const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
|
|
26
|
+
const messages = dig(msgs, ns);
|
|
27
|
+
return (key: string, values?: Record<string, unknown>): string => {
|
|
28
|
+
const value = dig(messages, key);
|
|
29
|
+
if (typeof value !== "string") return `${ns}.${key}`;
|
|
30
|
+
return value.replace(/\{(\w+)\}/g, (m, name: string) =>
|
|
31
|
+
values && name in values ? String(values[name]) : m,
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
jest.mock("next/headers", () => ({
|
|
39
|
+
__esModule: true,
|
|
40
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
41
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
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 session fields can mock without standing up Prisma.
|
|
46
|
+
jest.mock("../../../../../auth", () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const mockLeadsSubmit = jest.fn();
|
|
52
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
53
|
+
__esModule: true,
|
|
54
|
+
subscription: { me: jest.fn(), portalSession: jest.fn() },
|
|
55
|
+
leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
|
|
56
|
+
visitorForwardHeaders: () => undefined,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
jest.mock("next/navigation", () => ({
|
|
60
|
+
__esModule: true,
|
|
61
|
+
useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
|
|
62
|
+
usePathname: () => "/account/support",
|
|
63
|
+
redirect: jest.fn(),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
import { submitSupportRequest } from "../support-actions";
|
|
67
|
+
import Page from "../support/page";
|
|
68
|
+
|
|
69
|
+
const SESSION = {
|
|
70
|
+
user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
mockGetSession.mockReset().mockResolvedValue(SESSION);
|
|
75
|
+
mockLeadsSubmit
|
|
76
|
+
.mockReset()
|
|
77
|
+
.mockResolvedValue({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
async function renderPage(): Promise<void> {
|
|
81
|
+
const element = await Page({ params: Promise.resolve({ locale: "en" }) });
|
|
82
|
+
render(element);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function submit(subject: string, message: string): Promise<void> {
|
|
86
|
+
fireEvent.change(screen.getByLabelText("Subject"), { target: { value: subject } });
|
|
87
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: message } });
|
|
88
|
+
await act(async () => {
|
|
89
|
+
fireEvent.click(screen.getByTestId("account-support-submit"));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("Contact support (AC-9, D11)", () => {
|
|
94
|
+
it("the primary is 44px on a phone only — the 36px default from md up (DESIGN.md §4)", async () => {
|
|
95
|
+
await renderPage();
|
|
96
|
+
const submit = screen.getByTestId("account-support-submit");
|
|
97
|
+
expect(submit).toHaveClass("max-md:min-h-11");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders the page heading and says where the reply goes", async () => {
|
|
101
|
+
await renderPage();
|
|
102
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Contact support");
|
|
103
|
+
expect(screen.getByTestId("account-support")).toHaveTextContent("ada@example.com");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('posts { kind: "support", subject, message } with source "web", and confirms on 201', async () => {
|
|
107
|
+
await renderPage();
|
|
108
|
+
await submit("Billing question", "Where is my receipt?");
|
|
109
|
+
await waitFor(() => expect(mockLeadsSubmit).toHaveBeenCalledTimes(1));
|
|
110
|
+
expect(mockLeadsSubmit.mock.calls[0]).toEqual([
|
|
111
|
+
{
|
|
112
|
+
source: "web",
|
|
113
|
+
email: "ada@example.com",
|
|
114
|
+
payload: {
|
|
115
|
+
kind: "support",
|
|
116
|
+
subject: "Billing question",
|
|
117
|
+
message: "Where is my receipt?",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
await waitFor(() =>
|
|
122
|
+
expect(screen.getByRole("status")).toHaveTextContent("your request is on its way"),
|
|
123
|
+
);
|
|
124
|
+
expect(screen.queryByTestId("account-support-submit")).not.toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("a missing message is refused without a call", async () => {
|
|
128
|
+
await renderPage();
|
|
129
|
+
await submit("Billing question", " ");
|
|
130
|
+
expect(mockLeadsSubmit).not.toHaveBeenCalled();
|
|
131
|
+
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
132
|
+
"Fill in both the subject and the message.",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("a failed submit says so and keeps what was typed", async () => {
|
|
137
|
+
mockLeadsSubmit.mockResolvedValue({ ok: false, status: 500, body: {} });
|
|
138
|
+
await renderPage();
|
|
139
|
+
await submit("Billing question", "Where is my receipt?");
|
|
140
|
+
await waitFor(() =>
|
|
141
|
+
expect(screen.getByRole("alert")).toHaveTextContent("We couldn't send your request."),
|
|
142
|
+
);
|
|
143
|
+
expect(screen.getByLabelText("Subject")).toHaveValue("Billing question");
|
|
144
|
+
expect(screen.getByLabelText("Message")).toHaveValue("Where is my receipt?");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("a deployment that accepts no contact message says requests can't be sent", async () => {
|
|
148
|
+
mockLeadsSubmit.mockResolvedValue({
|
|
149
|
+
ok: false,
|
|
150
|
+
status: 503,
|
|
151
|
+
body: { error: "lead_capture_not_configured" },
|
|
152
|
+
});
|
|
153
|
+
await renderPage();
|
|
154
|
+
await submit("Billing question", "Where is my receipt?");
|
|
155
|
+
await waitFor(() =>
|
|
156
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Requests can't be sent from this site yet."),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("submitSupportRequest — the Server Action's own guards", () => {
|
|
162
|
+
function form(fields: Record<string, string>): FormData {
|
|
163
|
+
const data = new FormData();
|
|
164
|
+
for (const [key, value] of Object.entries(fields)) data.set(key, value);
|
|
165
|
+
return data;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
it("with no session, files nothing", async () => {
|
|
169
|
+
mockGetSession.mockResolvedValue(null);
|
|
170
|
+
const state = await submitSupportRequest(
|
|
171
|
+
{ kind: "idle" },
|
|
172
|
+
form({ kind: "support", subject: "Hi", message: "Help" }),
|
|
173
|
+
);
|
|
174
|
+
expect(state).toEqual({ kind: "error", reason: "failed" });
|
|
175
|
+
expect(mockLeadsSubmit).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("refuses an over-length subject or message", async () => {
|
|
179
|
+
const long = await submitSupportRequest(
|
|
180
|
+
{ kind: "idle" },
|
|
181
|
+
form({ kind: "support", subject: "x".repeat(201), message: "Help" }),
|
|
182
|
+
);
|
|
183
|
+
expect(long).toEqual({ kind: "error", reason: "required" });
|
|
184
|
+
const longer = await submitSupportRequest(
|
|
185
|
+
{ kind: "idle" },
|
|
186
|
+
form({ kind: "support", subject: "Hi", message: "x".repeat(5001) }),
|
|
187
|
+
);
|
|
188
|
+
expect(longer).toEqual({ kind: "error", reason: "required" });
|
|
189
|
+
expect(mockLeadsSubmit).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("any kind other than deletion files as support; the email is always the session's", async () => {
|
|
193
|
+
await submitSupportRequest(
|
|
194
|
+
{ kind: "idle" },
|
|
195
|
+
form({ kind: "refund", subject: "Hi", message: "Help", email: "someone@else.com" }),
|
|
196
|
+
);
|
|
197
|
+
expect(mockLeadsSubmit).toHaveBeenCalledWith({
|
|
198
|
+
source: "web",
|
|
199
|
+
email: "ada@example.com",
|
|
200
|
+
payload: { kind: "support", subject: "Hi", message: "Help" },
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|