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