import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import Settings from "../routes/Settings.svelte"; import { clearMocks, errorResponse, jsonResponse, mockEndpoint, setupAuthenticatedUser, setupFetchMock, setupUnauthenticatedUser, } from "./mocks"; describe("Settings", () => { beforeEach(() => { clearMocks(); setupFetchMock(); window.confirm = vi.fn(() => true); }); describe("authentication guard", () => { it("redirects to login when not authenticated", async () => { setupUnauthenticatedUser(); render(Settings); await waitFor(() => { expect(window.location.hash).toBe("#/login"); }); }); }); describe("page structure", () => { beforeEach(() => { setupAuthenticatedUser(); }); it("displays all page elements and sections", async () => { render(Settings); await waitFor(() => { expect( screen.getByRole("heading", { name: /account settings/i, level: 1 }), ).toBeInTheDocument(); expect(screen.getByRole("link", { name: /dashboard/i })) .toHaveAttribute("href", "#/dashboard"); expect(screen.getByRole("heading", { name: /change email/i })) .toBeInTheDocument(); expect(screen.getByRole("heading", { name: /change handle/i })) .toBeInTheDocument(); expect(screen.getByRole("heading", { name: /delete account/i })) .toBeInTheDocument(); }); }); }); describe("email change", () => { beforeEach(() => { setupAuthenticatedUser(); }); it("displays current email and input field", async () => { render(Settings); await waitFor(() => { expect(screen.getByText(/current: test@example.com/i)) .toBeInTheDocument(); expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); }); }); it("calls requestEmailUpdate when submitting", async () => { let requestCalled = false; mockEndpoint("com.atproto.server.requestEmailUpdate", () => { requestCalled = true; return jsonResponse({ tokenRequired: true }); }); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: "newemail@example.com" }, }); await fireEvent.click( screen.getByRole("button", { name: /change email/i }), ); await waitFor(() => { expect(requestCalled).toBe(true); }); }); it("shows verification code input when token is required", async () => { mockEndpoint( "com.atproto.server.requestEmailUpdate", () => jsonResponse({ tokenRequired: true }), ); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: "newemail@example.com" }, }); await fireEvent.click( screen.getByRole("button", { name: /change email/i }), ); await waitFor(() => { expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /confirm email change/i })) .toBeInTheDocument(); }); }); it("calls updateEmail with token when confirming", async () => { let updateCalled = false; let capturedBody: Record | null = null; mockEndpoint( "com.atproto.server.requestEmailUpdate", () => jsonResponse({ tokenRequired: true }), ); mockEndpoint("com.atproto.server.updateEmail", (_url, options) => { updateCalled = true; capturedBody = JSON.parse((options?.body as string) || "{}"); return jsonResponse({}); }); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: "newemail@example.com" }, }); await fireEvent.click( screen.getByRole("button", { name: /change email/i }), ); await waitFor(() => { expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: "123456" }, }); await fireEvent.click( screen.getByRole("button", { name: /confirm email change/i }), ); await waitFor(() => { expect(updateCalled).toBe(true); expect(capturedBody?.email).toBe("newemail@example.com"); expect(capturedBody?.token).toBe("123456"); }); }); it("shows success message after email update", async () => { mockEndpoint( "com.atproto.server.requestEmailUpdate", () => jsonResponse({ tokenRequired: true }), ); mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: "new@test.com" }, }); await fireEvent.click( screen.getByRole("button", { name: /change email/i }), ); await waitFor(() => { expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: "123456" }, }); await fireEvent.click( screen.getByRole("button", { name: /confirm email change/i }), ); await waitFor(() => { expect(screen.getByText(/email updated successfully/i)) .toBeInTheDocument(); }); }); it("shows cancel button to return to email form", async () => { mockEndpoint( "com.atproto.server.requestEmailUpdate", () => jsonResponse({ tokenRequired: true }), ); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: "new@test.com" }, }); await fireEvent.click( screen.getByRole("button", { name: /change email/i }), ); await waitFor(() => { expect(screen.getByRole("button", { name: /cancel/i })) .toBeInTheDocument(); }); await fireEvent.click(screen.getByRole("button", { name: /cancel/i })); await waitFor(() => { expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); expect(screen.queryByLabelText(/verification code/i)).not .toBeInTheDocument(); }); }); it("shows error when email update fails", async () => { mockEndpoint( "com.atproto.server.requestEmailUpdate", () => errorResponse("InvalidEmail", "Invalid email format", 400), ); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: "invalid@test.com" }, }); await waitFor(() => { expect(screen.getByRole("button", { name: /change email/i })).not .toBeDisabled(); }); await fireEvent.click( screen.getByRole("button", { name: /change email/i }), ); await waitFor(() => { expect(screen.getByText(/invalid email format/i)).toBeInTheDocument(); }); }); }); describe("handle change", () => { beforeEach(() => { setupAuthenticatedUser(); }); it("displays current handle", async () => { render(Settings); await waitFor(() => { expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i)) .toBeInTheDocument(); }); }); it("calls updateHandle with new handle", async () => { let capturedHandle: string | null = null; mockEndpoint("com.atproto.identity.updateHandle", (_url, options) => { const body = JSON.parse((options?.body as string) || "{}"); capturedHandle = body.handle; return jsonResponse({}); }); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: "newhandle.bsky.social" }, }); await fireEvent.click( screen.getByRole("button", { name: /change handle/i }), ); await waitFor(() => { expect(capturedHandle).toBe("newhandle.bsky.social"); }); }); it("shows success message after handle change", async () => { mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: "newhandle" }, }); await fireEvent.click( screen.getByRole("button", { name: /change handle/i }), ); await waitFor(() => { expect(screen.getByText(/handle updated successfully/i)) .toBeInTheDocument(); }); }); it("shows error when handle change fails", async () => { mockEndpoint( "com.atproto.identity.updateHandle", () => errorResponse("HandleNotAvailable", "Handle is already taken", 400), ); render(Settings); await waitFor(() => { expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: "taken" }, }); await fireEvent.click( screen.getByRole("button", { name: /change handle/i }), ); await waitFor(() => { expect(screen.getByText(/handle is already taken/i)) .toBeInTheDocument(); }); }); }); describe("account deletion", () => { beforeEach(() => { setupAuthenticatedUser(); mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({})); }); it("displays delete section with warning and request button", async () => { render(Settings); await waitFor(() => { expect(screen.getByText(/this action is irreversible/i)) .toBeInTheDocument(); expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); }); it("calls requestAccountDelete when clicking request", async () => { let requestCalled = false; mockEndpoint("com.atproto.server.requestAccountDelete", () => { requestCalled = true; return jsonResponse({}); }); render(Settings); await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); await fireEvent.click( screen.getByRole("button", { name: /request account deletion/i }), ); await waitFor(() => { expect(requestCalled).toBe(true); }); }); it("shows confirmation form after requesting deletion", async () => { mockEndpoint( "com.atproto.server.requestAccountDelete", () => jsonResponse({}), ); render(Settings); await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); await fireEvent.click( screen.getByRole("button", { name: /request account deletion/i }), ); await waitFor(() => { expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); expect(screen.getByLabelText(/your password/i)).toBeInTheDocument(); expect( screen.getByRole("button", { name: /permanently delete account/i }), ).toBeInTheDocument(); }); }); it("shows confirmation dialog before final deletion", async () => { const confirmSpy = vi.fn(() => false); window.confirm = confirmSpy; mockEndpoint( "com.atproto.server.requestAccountDelete", () => jsonResponse({}), ); render(Settings); await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); await fireEvent.click( screen.getByRole("button", { name: /request account deletion/i }), ); await waitFor(() => { expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: "ABC123" }, }); await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: "password" }, }); await fireEvent.click( screen.getByRole("button", { name: /permanently delete account/i }), ); expect(confirmSpy).toHaveBeenCalledWith( expect.stringContaining("absolutely sure"), ); }); it("calls deleteAccount with correct parameters", async () => { window.confirm = vi.fn(() => true); let capturedBody: Record | null = null; mockEndpoint( "com.atproto.server.requestAccountDelete", () => jsonResponse({}), ); mockEndpoint("com.atproto.server.deleteAccount", (_url, options) => { capturedBody = JSON.parse((options?.body as string) || "{}"); return jsonResponse({}); }); render(Settings); await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); await fireEvent.click( screen.getByRole("button", { name: /request account deletion/i }), ); await waitFor(() => { expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: "DEL123" }, }); await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: "mypassword" }, }); await fireEvent.click( screen.getByRole("button", { name: /permanently delete account/i }), ); await waitFor(() => { expect(capturedBody?.token).toBe("DEL123"); expect(capturedBody?.password).toBe("mypassword"); expect(capturedBody?.did).toBe("did:web:test.tranquil.dev:u:testuser"); }); }); it("navigates to login after successful deletion", async () => { window.confirm = vi.fn(() => true); mockEndpoint( "com.atproto.server.requestAccountDelete", () => jsonResponse({}), ); mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({})); render(Settings); await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); await fireEvent.click( screen.getByRole("button", { name: /request account deletion/i }), ); await waitFor(() => { expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: "DEL123" }, }); await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: "password" }, }); await fireEvent.click( screen.getByRole("button", { name: /permanently delete account/i }), ); await waitFor(() => { expect(window.location.hash).toBe("#/login"); }); }); it("shows cancel button to return to request state", async () => { mockEndpoint( "com.atproto.server.requestAccountDelete", () => jsonResponse({}), ); render(Settings); await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); await fireEvent.click( screen.getByRole("button", { name: /request account deletion/i }), ); await waitFor(() => { const cancelButtons = screen.getAllByRole("button", { name: /cancel/i, }); expect(cancelButtons.length).toBeGreaterThan(0); }); const deleteHeading = screen.getByRole("heading", { name: /delete account/i, }); const deleteSection = deleteHeading.closest("section"); const cancelButton = deleteSection?.querySelector("button.secondary"); if (cancelButton) { await fireEvent.click(cancelButton); } await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); }); it("shows error when deletion fails", async () => { window.confirm = vi.fn(() => true); mockEndpoint( "com.atproto.server.requestAccountDelete", () => jsonResponse({}), ); mockEndpoint( "com.atproto.server.deleteAccount", () => errorResponse("InvalidToken", "Invalid confirmation code", 400), ); render(Settings); await waitFor(() => { expect( screen.getByRole("button", { name: /request account deletion/i }), ).toBeInTheDocument(); }); await fireEvent.click( screen.getByRole("button", { name: /request account deletion/i }), ); await waitFor(() => { expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); }); await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: "WRONG" }, }); await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: "password" }, }); await fireEvent.click( screen.getByRole("button", { name: /permanently delete account/i }), ); await waitFor(() => { expect(screen.getByText(/invalid confirmation code/i)) .toBeInTheDocument(); }); }); }); });