this repo has no description
at main 20 kB view raw
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});