this repo has no description
1import { beforeEach, describe, expect, it, vi } from "vitest";
2import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3import Settings from "../routes/Settings.svelte";
4import {
5 clearMocks,
6 errorResponse,
7 getErrorToasts,
8 getToasts,
9 jsonResponse,
10 mockData,
11 mockEndpoint,
12 setupAuthenticatedUser,
13 setupDefaultMocks,
14 setupUnauthenticatedUser,
15} from "./mocks";
16describe("Settings", () => {
17 beforeEach(() => {
18 clearMocks();
19 setupDefaultMocks();
20 globalThis.confirm = vi.fn(() => true);
21 });
22 describe("authentication guard", () => {
23 it("redirects to login when not authenticated", async () => {
24 setupUnauthenticatedUser();
25 render(Settings);
26 await waitFor(() => {
27 expect(globalThis.location.pathname).toBe("/app/login");
28 });
29 });
30 });
31 describe("page structure", () => {
32 beforeEach(() => {
33 setupAuthenticatedUser();
34 });
35 it("displays all page elements and sections", async () => {
36 render(Settings);
37 await waitFor(() => {
38 expect(
39 screen.getByRole("heading", { name: /account settings/i, level: 1 }),
40 ).toBeInTheDocument();
41 expect(screen.getByRole("link", { name: /dashboard/i }))
42 .toHaveAttribute("href", "/app/dashboard");
43 expect(screen.getByRole("heading", { name: /change email/i }))
44 .toBeInTheDocument();
45 expect(screen.getByRole("heading", { name: /change handle/i }))
46 .toBeInTheDocument();
47 expect(screen.getByRole("heading", { name: /delete account/i }))
48 .toBeInTheDocument();
49 });
50 });
51 });
52 describe("email change", () => {
53 beforeEach(() => {
54 setupAuthenticatedUser();
55 });
56 it("displays current email and change button", async () => {
57 render(Settings);
58 await waitFor(() => {
59 expect(screen.getByText(/current.*test@example.com/i))
60 .toBeInTheDocument();
61 expect(screen.getByRole("button", { name: /change email/i }))
62 .toBeInTheDocument();
63 });
64 });
65 it("calls requestEmailUpdate when clicking change email button", async () => {
66 let requestCalled = false;
67 mockEndpoint("com.atproto.server.requestEmailUpdate", () => {
68 requestCalled = true;
69 return jsonResponse({ tokenRequired: true });
70 });
71 render(Settings);
72 await waitFor(() => {
73 expect(screen.getByRole("button", { name: /change email/i }))
74 .toBeInTheDocument();
75 });
76 await fireEvent.click(
77 screen.getByRole("button", { name: /change email/i }),
78 );
79 await waitFor(() => {
80 expect(requestCalled).toBe(true);
81 });
82 });
83 it("shows verification code and new email inputs when token is required", async () => {
84 mockEndpoint(
85 "com.atproto.server.requestEmailUpdate",
86 () => jsonResponse({ tokenRequired: true }),
87 );
88 render(Settings);
89 await waitFor(() => {
90 expect(screen.getByRole("button", { name: /change email/i }))
91 .toBeInTheDocument();
92 });
93 await fireEvent.click(
94 screen.getByRole("button", { name: /change email/i }),
95 );
96 await waitFor(() => {
97 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
98 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
99 expect(screen.getByRole("button", { name: /confirm email change/i }))
100 .toBeInTheDocument();
101 });
102 });
103 it("calls updateEmail with token when confirming", async () => {
104 let updateCalled = false;
105 let capturedBody: Record<string, string> | null = null;
106 mockEndpoint(
107 "com.atproto.server.requestEmailUpdate",
108 () => jsonResponse({ tokenRequired: true }),
109 );
110 mockEndpoint("com.atproto.server.updateEmail", (_url, options) => {
111 updateCalled = true;
112 capturedBody = JSON.parse((options?.body as string) || "{}");
113 return jsonResponse({});
114 });
115 mockEndpoint(
116 "com.atproto.server.getSession",
117 () => jsonResponse(mockData.session()),
118 );
119 render(Settings);
120 await waitFor(() => {
121 expect(screen.getByRole("button", { name: /change email/i }))
122 .toBeInTheDocument();
123 });
124 await fireEvent.click(
125 screen.getByRole("button", { name: /change email/i }),
126 );
127 await waitFor(() => {
128 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
129 });
130 await fireEvent.input(screen.getByLabelText(/verification code/i), {
131 target: { value: "123456" },
132 });
133 await fireEvent.input(screen.getByLabelText(/new email/i), {
134 target: { value: "newemail@example.com" },
135 });
136 await fireEvent.click(
137 screen.getByRole("button", { name: /confirm email change/i }),
138 );
139 await waitFor(() => {
140 expect(updateCalled).toBe(true);
141 expect(capturedBody?.email).toBe("newemail@example.com");
142 expect(capturedBody?.token).toBe("123456");
143 });
144 });
145 it("shows success toast after email update", async () => {
146 mockEndpoint(
147 "com.atproto.server.requestEmailUpdate",
148 () => jsonResponse({ tokenRequired: true }),
149 );
150 mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
151 mockEndpoint(
152 "com.atproto.server.getSession",
153 () => jsonResponse(mockData.session()),
154 );
155 render(Settings);
156 await waitFor(() => {
157 expect(screen.getByRole("button", { name: /change email/i }))
158 .toBeInTheDocument();
159 });
160 await fireEvent.click(
161 screen.getByRole("button", { name: /change email/i }),
162 );
163 await waitFor(() => {
164 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
165 });
166 await fireEvent.input(screen.getByLabelText(/verification code/i), {
167 target: { value: "123456" },
168 });
169 await fireEvent.input(screen.getByLabelText(/new email/i), {
170 target: { value: "new@test.com" },
171 });
172 await fireEvent.click(
173 screen.getByRole("button", { name: /confirm email change/i }),
174 );
175 await waitFor(() => {
176 const toasts = getToasts();
177 expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true);
178 });
179 });
180 it("shows cancel button to return to initial state", async () => {
181 mockEndpoint(
182 "com.atproto.server.requestEmailUpdate",
183 () => jsonResponse({ tokenRequired: true }),
184 );
185 render(Settings);
186 await waitFor(() => {
187 expect(screen.getByRole("button", { name: /change email/i }))
188 .toBeInTheDocument();
189 });
190 await fireEvent.click(
191 screen.getByRole("button", { name: /change email/i }),
192 );
193 await waitFor(() => {
194 expect(screen.getByRole("button", { name: /cancel/i }))
195 .toBeInTheDocument();
196 });
197 const emailSection = screen.getByRole("heading", {
198 name: /change email/i,
199 })
200 .closest("section");
201 const cancelButton = emailSection?.querySelector("button.secondary");
202 if (cancelButton) {
203 await fireEvent.click(cancelButton);
204 }
205 await waitFor(() => {
206 expect(screen.queryByLabelText(/verification code/i)).not
207 .toBeInTheDocument();
208 });
209 });
210 it("shows error toast when request fails", async () => {
211 mockEndpoint(
212 "com.atproto.server.requestEmailUpdate",
213 () => errorResponse("InvalidEmail", "Invalid email format", 400),
214 );
215 render(Settings);
216 await waitFor(() => {
217 expect(screen.getByRole("button", { name: /change email/i }))
218 .toBeInTheDocument();
219 });
220 await fireEvent.click(
221 screen.getByRole("button", { name: /change email/i }),
222 );
223 await waitFor(() => {
224 const errors = getErrorToasts();
225 expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true);
226 });
227 });
228 });
229 describe("handle change", () => {
230 beforeEach(() => {
231 setupAuthenticatedUser();
232 mockEndpoint(
233 "com.atproto.server.describeServer",
234 () => jsonResponse(mockData.describeServer()),
235 );
236 });
237 it("displays current handle", async () => {
238 render(Settings);
239 await waitFor(() => {
240 expect(screen.getByText(/current.*@testuser\.test\.tranquil\.dev/i))
241 .toBeInTheDocument();
242 });
243 });
244 it("shows PDS handle and custom domain tabs", async () => {
245 render(Settings);
246 await waitFor(() => {
247 expect(screen.getByRole("button", { name: /pds handle/i }))
248 .toBeInTheDocument();
249 expect(screen.getByRole("button", { name: /custom domain/i }))
250 .toBeInTheDocument();
251 });
252 });
253 it("allows entering handle and shows domain suffix", async () => {
254 render(Settings);
255 await waitFor(() => {
256 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
257 expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
258 });
259 const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
260 await fireEvent.input(input, {
261 target: { value: "newhandle" },
262 });
263 expect(input.value).toBe("newhandle");
264 expect(screen.getByRole("button", { name: /change handle/i }))
265 .toBeInTheDocument();
266 });
267 it("shows success toast after handle change", async () => {
268 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
269 mockEndpoint(
270 "com.atproto.server.getSession",
271 () => jsonResponse(mockData.session()),
272 );
273 render(Settings);
274 await waitFor(() => {
275 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
276 expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
277 });
278 const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
279 await fireEvent.input(input, {
280 target: { value: "newhandle" },
281 });
282 const button = screen.getByRole("button", { name: /change handle/i });
283 await fireEvent.submit(button.closest("form")!);
284 await waitFor(() => {
285 const toasts = getToasts();
286 expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true);
287 });
288 });
289 it("shows error toast when handle change fails", async () => {
290 mockEndpoint(
291 "com.atproto.identity.updateHandle",
292 () =>
293 errorResponse("HandleNotAvailable", "Handle is already taken", 400),
294 );
295 render(Settings);
296 await waitFor(() => {
297 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
298 expect(screen.getByText(/\.test\.tranquil\.dev/i)).toBeInTheDocument();
299 });
300 const input = screen.getByLabelText(/new handle/i) as HTMLInputElement;
301 await fireEvent.input(input, {
302 target: { value: "taken" },
303 });
304 expect(input.value).toBe("taken");
305 const button = screen.getByRole("button", { name: /change handle/i });
306 await fireEvent.submit(button.closest("form")!);
307 await waitFor(() => {
308 const errors = getErrorToasts();
309 expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true);
310 });
311 });
312 });
313 describe("account deletion", () => {
314 beforeEach(() => {
315 setupAuthenticatedUser();
316 mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
317 });
318 it("displays delete section with warning and request button", async () => {
319 render(Settings);
320 await waitFor(() => {
321 expect(screen.getByText(/this action is irreversible/i))
322 .toBeInTheDocument();
323 expect(
324 screen.getByRole("button", { name: /request account deletion/i }),
325 ).toBeInTheDocument();
326 });
327 });
328 it("calls requestAccountDelete when clicking request", async () => {
329 let requestCalled = false;
330 mockEndpoint("com.atproto.server.requestAccountDelete", () => {
331 requestCalled = true;
332 return jsonResponse({});
333 });
334 render(Settings);
335 await waitFor(() => {
336 expect(
337 screen.getByRole("button", { name: /request account deletion/i }),
338 ).toBeInTheDocument();
339 });
340 await fireEvent.click(
341 screen.getByRole("button", { name: /request account deletion/i }),
342 );
343 await waitFor(() => {
344 expect(requestCalled).toBe(true);
345 });
346 });
347 it("shows confirmation form after requesting deletion", async () => {
348 mockEndpoint(
349 "com.atproto.server.requestAccountDelete",
350 () => jsonResponse({}),
351 );
352 render(Settings);
353 await waitFor(() => {
354 expect(
355 screen.getByRole("button", { name: /request account deletion/i }),
356 ).toBeInTheDocument();
357 });
358 await fireEvent.click(
359 screen.getByRole("button", { name: /request account deletion/i }),
360 );
361 await waitFor(() => {
362 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
363 expect(screen.getByLabelText(/your password/i)).toBeInTheDocument();
364 expect(
365 screen.getByRole("button", { name: /permanently delete account/i }),
366 ).toBeInTheDocument();
367 });
368 });
369 it("shows confirmation dialog before final deletion", async () => {
370 const confirmSpy = vi.fn(() => false);
371 globalThis.confirm = confirmSpy;
372 mockEndpoint(
373 "com.atproto.server.requestAccountDelete",
374 () => jsonResponse({}),
375 );
376 render(Settings);
377 await waitFor(() => {
378 expect(
379 screen.getByRole("button", { name: /request account deletion/i }),
380 ).toBeInTheDocument();
381 });
382 await fireEvent.click(
383 screen.getByRole("button", { name: /request account deletion/i }),
384 );
385 await waitFor(() => {
386 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
387 });
388 await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
389 target: { value: "ABC123" },
390 });
391 await fireEvent.input(screen.getByLabelText(/your password/i), {
392 target: { value: "password" },
393 });
394 await fireEvent.click(
395 screen.getByRole("button", { name: /permanently delete account/i }),
396 );
397 expect(confirmSpy).toHaveBeenCalledWith(
398 expect.stringContaining("absolutely sure"),
399 );
400 });
401 it("calls deleteAccount with correct parameters", async () => {
402 globalThis.confirm = vi.fn(() => true);
403 let capturedBody: Record<string, string> | null = null;
404 mockEndpoint(
405 "com.atproto.server.requestAccountDelete",
406 () => jsonResponse({}),
407 );
408 mockEndpoint("com.atproto.server.deleteAccount", (_url, options) => {
409 capturedBody = JSON.parse((options?.body as string) || "{}");
410 return jsonResponse({});
411 });
412 render(Settings);
413 await waitFor(() => {
414 expect(
415 screen.getByRole("button", { name: /request account deletion/i }),
416 ).toBeInTheDocument();
417 });
418 await fireEvent.click(
419 screen.getByRole("button", { name: /request account deletion/i }),
420 );
421 await waitFor(() => {
422 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
423 });
424 await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
425 target: { value: "DEL123" },
426 });
427 await fireEvent.input(screen.getByLabelText(/your password/i), {
428 target: { value: "mypassword" },
429 });
430 await fireEvent.click(
431 screen.getByRole("button", { name: /permanently delete account/i }),
432 );
433 await waitFor(() => {
434 expect(capturedBody?.token).toBe("DEL123");
435 expect(capturedBody?.password).toBe("mypassword");
436 expect(capturedBody?.did).toBe("did:web:test.tranquil.dev:u:testuser");
437 });
438 });
439 it("navigates to login after successful deletion", async () => {
440 globalThis.confirm = vi.fn(() => true);
441 mockEndpoint(
442 "com.atproto.server.requestAccountDelete",
443 () => jsonResponse({}),
444 );
445 mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({}));
446 render(Settings);
447 await waitFor(() => {
448 expect(
449 screen.getByRole("button", { name: /request account deletion/i }),
450 ).toBeInTheDocument();
451 });
452 await fireEvent.click(
453 screen.getByRole("button", { name: /request account deletion/i }),
454 );
455 await waitFor(() => {
456 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
457 });
458 await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
459 target: { value: "DEL123" },
460 });
461 await fireEvent.input(screen.getByLabelText(/your password/i), {
462 target: { value: "password" },
463 });
464 await fireEvent.click(
465 screen.getByRole("button", { name: /permanently delete account/i }),
466 );
467 await waitFor(() => {
468 expect(globalThis.location.pathname).toBe("/app/login");
469 });
470 });
471 it("shows cancel button to return to request state", async () => {
472 mockEndpoint(
473 "com.atproto.server.requestAccountDelete",
474 () => jsonResponse({}),
475 );
476 render(Settings);
477 await waitFor(() => {
478 expect(
479 screen.getByRole("button", { name: /request account deletion/i }),
480 ).toBeInTheDocument();
481 });
482 await fireEvent.click(
483 screen.getByRole("button", { name: /request account deletion/i }),
484 );
485 await waitFor(() => {
486 const cancelButtons = screen.getAllByRole("button", {
487 name: /cancel/i,
488 });
489 expect(cancelButtons.length).toBeGreaterThan(0);
490 });
491 const deleteHeading = screen.getByRole("heading", {
492 name: /delete account/i,
493 });
494 const deleteSection = deleteHeading.closest("section");
495 const cancelButton = deleteSection?.querySelector("button.secondary");
496 if (cancelButton) {
497 await fireEvent.click(cancelButton);
498 }
499 await waitFor(() => {
500 expect(
501 screen.getByRole("button", { name: /request account deletion/i }),
502 ).toBeInTheDocument();
503 });
504 });
505 it("shows error toast when deletion fails", async () => {
506 globalThis.confirm = vi.fn(() => true);
507 mockEndpoint(
508 "com.atproto.server.requestAccountDelete",
509 () => jsonResponse({}),
510 );
511 mockEndpoint(
512 "com.atproto.server.deleteAccount",
513 () => errorResponse("InvalidToken", "Invalid confirmation code", 400),
514 );
515 render(Settings);
516 await waitFor(() => {
517 expect(
518 screen.getByRole("button", { name: /request account deletion/i }),
519 ).toBeInTheDocument();
520 });
521 await fireEvent.click(
522 screen.getByRole("button", { name: /request account deletion/i }),
523 );
524 await waitFor(() => {
525 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
526 });
527 await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
528 target: { value: "WRONG" },
529 });
530 await fireEvent.input(screen.getByLabelText(/your password/i), {
531 target: { value: "password" },
532 });
533 await fireEvent.click(
534 screen.getByRole("button", { name: /permanently delete account/i }),
535 );
536 await waitFor(() => {
537 const errors = getErrorToasts();
538 expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true);
539 });
540 });
541 });
542});