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