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});