Files
silo-server/web/src/pages/Profiles.test.tsx
T

250 lines
6.6 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Profile, UserLibrary } from "@/api/types";
const mocks = vi.hoisted(() => ({
useProfiles: vi.fn(),
useAvailableUserLibraries: vi.fn(),
useAuth: vi.fn(),
navigate: vi.fn(),
editorPin: "",
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useNavigate: () => mocks.navigate,
};
});
vi.mock("@/hooks/queries/profiles", () => ({
useProfiles: (...args: unknown[]) => mocks.useProfiles(...args),
}));
vi.mock("@/hooks/queries/libraries", () => ({
useAvailableUserLibraries: (...args: unknown[]) => mocks.useAvailableUserLibraries(...args),
}));
vi.mock("@/hooks/useAuth", () => ({
useAuth: (...args: unknown[]) => mocks.useAuth(...args),
}));
vi.mock("@/components/profiles/ProfileEditorDialog", () => ({
ProfileEditorDialog: ({
open,
onSaveSuccess,
}: {
open: boolean;
onSaveSuccess?: (profile: Profile, context: { mode: "create" | "edit"; pin: string }) => void;
}) =>
open ? (
<button
type="button"
onClick={() =>
onSaveSuccess?.(
makeProfile({
id: "created-profile",
name: "Created",
has_pin: mocks.editorPin !== "",
}),
{
mode: "create",
pin: mocks.editorPin,
},
)
}
>
save-editor
</button>
) : null,
}));
vi.mock("@/components/profiles/ProfilePinDialog", () => ({
ProfilePinDialog: ({
profile,
onVerified,
}: {
profile: Profile | null;
onVerified: (profile: Profile, token: string) => void;
}) =>
profile ? (
<button type="button" onClick={() => onVerified(profile, "verified-token")}>
confirm-pin
</button>
) : null,
}));
import Profiles from "./Profiles";
const libraries: UserLibrary[] = [{ id: 1, name: "Movies", type: "movies", sort_order: 0 }];
function makeProfile(overrides: Partial<Profile> = {}): Profile {
return {
id: "profile-1",
name: "Main",
avatar: "",
has_pin: false,
is_child: false,
is_primary: false,
max_content_rating: "",
quality_preference: "auto",
language: "en",
subtitle_language: "",
subtitle_mode: "auto",
show_forced_subtitles: true,
auto_skip_intro: false,
auto_skip_credits: false,
library_restrictions_enabled: false,
allowed_library_ids: null,
max_playback_quality: "",
created_at: "2026-04-06T00:00:00Z",
updated_at: "2026-04-06T00:00:00Z",
...overrides,
};
}
function findButton(container: HTMLElement, label: string) {
return Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.trim().includes(label),
);
}
async function click(element: Element | undefined) {
if (!element) {
throw new Error("element not found");
}
await act(async () => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
describe("Profiles", () => {
let container: HTMLDivElement;
let root: Root;
let selectProfile: ReturnType<typeof vi.fn>;
let verifyProfilePin: ReturnType<typeof vi.fn>;
let logout: ReturnType<typeof vi.fn>;
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
selectProfile = vi.fn();
verifyProfilePin = vi.fn().mockResolvedValue({
valid: true,
profile_token: "verified-token",
});
logout = vi.fn();
mocks.useProfiles.mockReset();
mocks.useAvailableUserLibraries.mockReset();
mocks.useAuth.mockReset();
mocks.navigate.mockReset();
mocks.editorPin = "";
mocks.useProfiles.mockReturnValue({
data: [makeProfile(), makeProfile({ id: "profile-2", name: "Guest" })],
isLoading: false,
});
mocks.useAvailableUserLibraries.mockReturnValue({
data: libraries,
isLoading: false,
});
mocks.useAuth.mockReturnValue({
selectProfile,
verifyProfilePin,
logout,
});
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
});
async function render(ui: ReactNode) {
await act(async () => {
root.render(ui);
});
}
it("keeps the selector focused on profile choice instead of profile management", async () => {
await render(<Profiles />);
expect(container.textContent).toContain("Who's watching?");
expect(container.textContent).not.toContain("Add Profile");
expect(container.textContent).not.toContain("Edit Profile");
expect(container.textContent).not.toContain("Delete profile");
});
it("creates and selects a profile from the empty state when no PIN is set", async () => {
mocks.useProfiles.mockReturnValue({
data: [],
isLoading: false,
});
await render(<Profiles />);
await click(findButton(container, "Create profile"));
await click(findButton(container, "save-editor"));
expect(selectProfile).toHaveBeenCalledWith(
expect.objectContaining({ id: "created-profile", name: "Created" }),
);
expect(mocks.navigate).toHaveBeenCalledWith("/");
});
it("verifies a newly created locked profile before selecting it", async () => {
mocks.useProfiles.mockReturnValue({
data: [],
isLoading: false,
});
mocks.editorPin = "1234";
await render(<Profiles />);
await click(findButton(container, "Create profile"));
await click(findButton(container, "save-editor"));
expect(verifyProfilePin).toHaveBeenCalledWith("created-profile", "1234");
expect(selectProfile).toHaveBeenCalledWith(
expect.objectContaining({ id: "created-profile" }),
"verified-token",
);
expect(mocks.navigate).toHaveBeenCalledWith("/");
});
it("verifies locked profiles before entering the app", async () => {
const lockedProfile = makeProfile({
id: "profile-2",
name: "Kids",
has_pin: true,
});
mocks.useProfiles.mockReturnValue({
data: [makeProfile(), lockedProfile],
isLoading: false,
});
await render(<Profiles />);
await click(findButton(container, "Kids"));
await click(findButton(container, "confirm-pin"));
expect(selectProfile).toHaveBeenCalledWith(lockedProfile, "verified-token");
expect(mocks.navigate).toHaveBeenCalledWith("/");
});
});