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