mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-22 01:06:05 +00:00
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
|
import AppPasswords from "../routes/AppPasswords.svelte";
|
|
import {
|
|
clearMocks,
|
|
errorResponse,
|
|
getErrorToasts,
|
|
jsonResponse,
|
|
mockData,
|
|
mockEndpoint,
|
|
setupAuthenticatedUser,
|
|
setupFetchMock,
|
|
setupIndexedDBMock,
|
|
setupUnauthenticatedUser,
|
|
} from "./mocks.ts";
|
|
import { unsafeAsISODateString } from "../lib/types/branded.ts";
|
|
describe("AppPasswords", () => {
|
|
beforeEach(() => {
|
|
clearMocks();
|
|
setupFetchMock();
|
|
setupIndexedDBMock();
|
|
globalThis.confirm = vi.fn(() => true);
|
|
});
|
|
describe("authentication guard", () => {
|
|
it("redirects to login when not authenticated", async () => {
|
|
setupUnauthenticatedUser();
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(globalThis.location.pathname).toBe("/app/login");
|
|
});
|
|
});
|
|
});
|
|
describe("page structure", () => {
|
|
beforeEach(() => {
|
|
setupAuthenticatedUser();
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [] }),
|
|
);
|
|
});
|
|
it("displays all page elements", async () => {
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByRole("heading", { name: /app passwords/i, level: 1 }),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("link", { name: /dashboard/i }))
|
|
.toHaveAttribute("href", "/app/dashboard");
|
|
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
describe("loading state", () => {
|
|
beforeEach(() => {
|
|
setupAuthenticatedUser();
|
|
});
|
|
it("shows loading skeleton while fetching passwords", () => {
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() =>
|
|
new Promise((resolve) =>
|
|
setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100)
|
|
),
|
|
);
|
|
const { container } = render(AppPasswords);
|
|
expect(container.querySelectorAll(".skeleton-item").length)
|
|
.toBeGreaterThan(0);
|
|
});
|
|
});
|
|
describe("empty state", () => {
|
|
beforeEach(() => {
|
|
setupAuthenticatedUser();
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [] }),
|
|
);
|
|
});
|
|
it("shows empty message when no passwords exist", async () => {
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
describe("password list", () => {
|
|
const testPasswords = [
|
|
mockData.appPassword({
|
|
name: "Graysky",
|
|
createdAt: unsafeAsISODateString("2024-01-15T10:00:00Z"),
|
|
}),
|
|
mockData.appPassword({
|
|
name: "Skeets",
|
|
createdAt: unsafeAsISODateString("2024-02-20T15:30:00Z"),
|
|
}),
|
|
];
|
|
beforeEach(() => {
|
|
setupAuthenticatedUser();
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: testPasswords }),
|
|
);
|
|
});
|
|
it("displays all app passwords with dates and revoke buttons", async () => {
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Graysky")).toBeInTheDocument();
|
|
expect(screen.getByText("Skeets")).toBeInTheDocument();
|
|
expect(screen.getByText(/created.*2024-01-15/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/created.*2024-02-20/i)).toBeInTheDocument();
|
|
expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength(
|
|
2,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
describe("create app password", () => {
|
|
beforeEach(() => {
|
|
setupAuthenticatedUser();
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [] }),
|
|
);
|
|
});
|
|
it("displays create form with input and button", async () => {
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /create/i }))
|
|
.toBeInTheDocument();
|
|
});
|
|
});
|
|
it("disables create button when input is empty", async () => {
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: /create/i })).toBeDisabled();
|
|
});
|
|
});
|
|
it("enables create button when input has value", async () => {
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
|
});
|
|
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
|
target: { value: "My New App" },
|
|
});
|
|
expect(screen.getByRole("button", { name: /create/i })).not
|
|
.toBeDisabled();
|
|
});
|
|
it("calls createAppPassword with correct name", async () => {
|
|
let capturedName: string | null = null;
|
|
mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => {
|
|
const body = JSON.parse((options?.body as string) || "{}");
|
|
capturedName = body.name;
|
|
return jsonResponse({
|
|
name: body.name,
|
|
password: "xxxx-xxxx-xxxx-xxxx",
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
});
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
|
});
|
|
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
|
target: { value: "Graysky" },
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
|
await waitFor(() => {
|
|
expect(capturedName).toBe("Graysky");
|
|
});
|
|
});
|
|
it("shows loading state while creating", async () => {
|
|
mockEndpoint("com.atproto.server.createAppPassword", async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
return jsonResponse({
|
|
name: "Test",
|
|
password: "xxxx-xxxx-xxxx-xxxx",
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
});
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
|
});
|
|
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
|
target: { value: "Test" },
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
|
expect(screen.getByRole("button", { name: /creating/i }))
|
|
.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled();
|
|
});
|
|
it("displays created password in success box and clears input", async () => {
|
|
mockEndpoint("com.atproto.server.createAppPassword", () =>
|
|
jsonResponse({
|
|
name: "MyApp",
|
|
password: "abcd-efgh-ijkl-mnop",
|
|
createdAt: new Date().toISOString(),
|
|
}));
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
|
});
|
|
const input = screen.getByPlaceholderText(
|
|
/app name/i,
|
|
) as HTMLInputElement;
|
|
await fireEvent.input(input, { target: { value: "MyApp" } });
|
|
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/save this app password/i)).toBeInTheDocument();
|
|
expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument();
|
|
expect(screen.getByText("MyApp")).toBeInTheDocument();
|
|
expect(input.value).toBe("");
|
|
});
|
|
});
|
|
it("dismisses created password box when clicking Done", async () => {
|
|
mockEndpoint("com.atproto.server.createAppPassword", () =>
|
|
jsonResponse({
|
|
name: "Test",
|
|
password: "xxxx-xxxx-xxxx-xxxx",
|
|
createdAt: new Date().toISOString(),
|
|
}));
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
|
});
|
|
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
|
target: { value: "Test" },
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/save this app password/i)).toBeInTheDocument();
|
|
});
|
|
await fireEvent.click(
|
|
screen.getByLabelText(/i have saved my app password/i),
|
|
);
|
|
await fireEvent.click(screen.getByRole("button", { name: /done/i }));
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/save this app password/i)).not
|
|
.toBeInTheDocument();
|
|
});
|
|
});
|
|
it("shows error toast when creation fails", async () => {
|
|
mockEndpoint(
|
|
"com.atproto.server.createAppPassword",
|
|
() => errorResponse("InvalidRequest", "Name already exists", 400),
|
|
);
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
|
});
|
|
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
|
target: { value: "Duplicate" },
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
|
await waitFor(() => {
|
|
const errors = getErrorToasts();
|
|
expect(errors.some((e) => /name already exists/i.test(e))).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
describe("revoke app password", () => {
|
|
const testPassword = mockData.appPassword({ name: "TestApp" });
|
|
beforeEach(() => {
|
|
setupAuthenticatedUser();
|
|
});
|
|
it("shows confirmation dialog before revoking", async () => {
|
|
const confirmSpy = vi.fn(() => false);
|
|
globalThis.confirm = confirmSpy;
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [testPassword] }),
|
|
);
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
|
expect(confirmSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("TestApp"),
|
|
);
|
|
});
|
|
it("does not revoke when confirmation is cancelled", async () => {
|
|
globalThis.confirm = vi.fn(() => false);
|
|
let revokeCalled = false;
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [testPassword] }),
|
|
);
|
|
mockEndpoint("com.atproto.server.revokeAppPassword", () => {
|
|
revokeCalled = true;
|
|
return jsonResponse({});
|
|
});
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
|
expect(revokeCalled).toBe(false);
|
|
});
|
|
it("calls revokeAppPassword with correct name", async () => {
|
|
globalThis.confirm = vi.fn(() => true);
|
|
let capturedName: string | null = null;
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [testPassword] }),
|
|
);
|
|
mockEndpoint("com.atproto.server.revokeAppPassword", (_url, options) => {
|
|
const body = JSON.parse((options?.body as string) || "{}");
|
|
capturedName = body.name;
|
|
return jsonResponse({});
|
|
});
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
|
await waitFor(() => {
|
|
expect(capturedName).toBe("TestApp");
|
|
});
|
|
});
|
|
it("shows loading state while revoking", async () => {
|
|
globalThis.confirm = vi.fn(() => true);
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [testPassword] }),
|
|
);
|
|
mockEndpoint("com.atproto.server.revokeAppPassword", async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
return jsonResponse({});
|
|
});
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
|
expect(screen.getByRole("button", { name: /revoking/i }))
|
|
.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled();
|
|
});
|
|
it("reloads password list after successful revocation", async () => {
|
|
globalThis.confirm = vi.fn(() => true);
|
|
let listCallCount = 0;
|
|
mockEndpoint("com.atproto.server.listAppPasswords", () => {
|
|
listCallCount++;
|
|
if (listCallCount === 1) {
|
|
return jsonResponse({ passwords: [testPassword] });
|
|
}
|
|
return jsonResponse({ passwords: [] });
|
|
});
|
|
mockEndpoint(
|
|
"com.atproto.server.revokeAppPassword",
|
|
() => jsonResponse({}),
|
|
);
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("TestApp")).not.toBeInTheDocument();
|
|
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
it("shows error toast when revocation fails", async () => {
|
|
globalThis.confirm = vi.fn(() => true);
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => jsonResponse({ passwords: [testPassword] }),
|
|
);
|
|
mockEndpoint(
|
|
"com.atproto.server.revokeAppPassword",
|
|
() => errorResponse("InternalError", "Server error", 500),
|
|
);
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
|
});
|
|
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
|
await waitFor(() => {
|
|
const errors = getErrorToasts();
|
|
expect(errors.some((e) => /server error/i.test(e))).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
describe("error handling", () => {
|
|
beforeEach(() => {
|
|
setupAuthenticatedUser();
|
|
});
|
|
it("shows error toast when loading passwords fails", async () => {
|
|
mockEndpoint(
|
|
"com.atproto.server.listAppPasswords",
|
|
() => errorResponse("InternalError", "Database connection failed", 500),
|
|
);
|
|
render(AppPasswords);
|
|
await waitFor(() => {
|
|
const errors = getErrorToasts();
|
|
expect(errors.some((e) => /database connection failed/i.test(e))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|