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