Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 579 lines 21 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.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});