Files
medassist-ng/frontend/src/test/components/Auth.test.tsx
T
Daniel Volz 943148fb49 feat: close modals with browser back button on mobile (#257)
* feat: close modals with browser back button on mobile

Create reusable useModalHistory hook that pushes history state when a
modal opens and listens for popstate to close it. Apply to ReportModal,
ClearMissedConfirm, ExportModal, ImportConfirm, and all modals using
ConfirmModal/ShareDialog/Auth/ExportModal base components. Escape key
handling was already in place for desktop.

Closes #253

* fix: update tests for renamed button labels and missing useModalHistory mock
2026-02-21 18:00:12 +01:00

944 lines
26 KiB
TypeScript

import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthPage, AuthProvider, LoginForm, RegisterForm, UserProfile, useAuth } from "../../components/Auth";
// Wrapper component for testing hooks that require AuthProvider
const wrapper = ({ children }: { children: React.ReactNode }) => <AuthProvider>{children}</AuthProvider>;
describe("AuthProvider", () => {
beforeEach(() => {
vi.resetAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
});
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it("provides auth context to children", () => {
render(
<AuthProvider>
<div data-testid="child">Child content</div>
</AuthProvider>
);
expect(screen.getByTestId("child")).toBeInTheDocument();
});
it("initializes with loading state", () => {
const { result } = renderHook(() => useAuth(), { wrapper });
// Initially loading
expect(result.current.loading).toBe(true);
});
it("fetches auth state on mount", async () => {
renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
});
});
it("fetches auth state only ONCE on mount (no infinite loop)", async () => {
// This test catches the infinite loop bug where fetchAuthState was in useEffect dependencies
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ authEnabled: false }),
});
renderHook(() => useAuth(), { wrapper });
// Wait for the initial fetch to complete
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith("/api/auth/state");
});
// Wait a bit more to ensure no additional calls happen
await new Promise((resolve) => setTimeout(resolve, 100));
// Should be called exactly once, not multiple times (which would indicate infinite loop)
const authStateCalls = (fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(call) => call[0] === "/api/auth/state"
);
expect(authStateCalls.length).toBe(1);
});
it("throws error when useAuth is used outside AuthProvider", () => {
expect(() => {
renderHook(() => useAuth());
}).toThrow("useAuth must be used within AuthProvider");
});
it("authFetch retries original request after token refresh", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }),
})
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ data: true }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const response = await result.current.authFetch("/api/medications", { method: "GET" });
expect(response.ok).toBe(true);
expect(fetch).toHaveBeenNthCalledWith(2, "/api/medications", {
method: "GET",
credentials: "include",
});
expect(fetch).toHaveBeenNthCalledWith(3, "/api/auth/refresh", {
method: "POST",
credentials: "include",
});
expect(fetch).toHaveBeenNthCalledWith(4, "/api/medications", {
method: "GET",
credentials: "include",
});
});
it("authFetch logs user out when refresh fails", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
})
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ id: 1, username: "tester" }) })
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: false, status: 401 });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.user?.username).toBe("tester");
});
await result.current.authFetch("/api/medications");
await waitFor(() => {
expect(result.current.user).toBeNull();
});
});
it("runs periodic token refresh when authenticated", async () => {
vi.useFakeTimers();
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
})
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "timer-user" }) })
.mockResolvedValueOnce({ ok: true, status: 200 });
renderHook(() => useAuth(), { wrapper });
await act(async () => {
await vi.runOnlyPendingTimersAsync();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(10 * 60 * 1000);
});
expect(fetch).toHaveBeenCalledWith(
"/api/auth/refresh",
expect.objectContaining({ method: "POST", credentials: "include" })
);
vi.useRealTimers();
});
});
describe("LoginForm", () => {
const mockAuthState = {
authEnabled: true,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: true,
needsSetup: false,
oidcProviderName: "",
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthState),
})
.mockResolvedValueOnce({
status: 401,
ok: false,
});
});
it("renders login form", async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
});
});
it("renders username and password fields", async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
});
});
it("renders remember me checkbox", async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/auth\.rememberMe/i)).toBeInTheDocument();
});
});
it("renders create account link when registration enabled", async () => {
const onSwitchToRegister = vi.fn();
render(
<AuthProvider>
<LoginForm onSwitchToRegister={onSwitchToRegister} />
</AuthProvider>
);
await waitFor(() => {
const createAccountBtn = screen.getByText(/auth\.createAccount/i);
expect(createAccountBtn).toBeInTheDocument();
});
});
it("handles form input changes", async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: "testuser" } });
fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: "password123" } });
expect(screen.getByLabelText(/auth\.username/i)).toHaveValue("testuser");
expect(screen.getByLabelText(/auth\.password/i)).toHaveValue("password123");
});
it("renders submit button", async () => {
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
);
await waitFor(() => {
const buttons = screen.getAllByRole("button");
const submitBtn = buttons.find((btn) => btn.getAttribute("type") === "submit");
expect(submitBtn).toBeInTheDocument();
});
});
it("submits login form and calls onSuccess", async () => {
vi.clearAllMocks();
const onSuccess = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
authEnabled: false,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: true,
needsSetup: false,
oidcProviderName: "",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ user: { id: 1, username: "testuser" } }),
});
render(
<AuthProvider>
<LoginForm onSuccess={onSuccess} />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: "testuser" } });
fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: "password123" } });
fireEvent.click(screen.getByRole("button", { name: /auth\.login/i }));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled();
});
});
});
describe("RegisterForm", () => {
const mockAuthState = {
authEnabled: true,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: false,
needsSetup: true,
oidcProviderName: "",
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthState),
})
.mockResolvedValueOnce({
status: 401,
ok: false,
});
});
it("renders registration form", async () => {
render(
<AuthProvider>
<RegisterForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/MedAssist/i)).toBeInTheDocument();
});
});
it("renders all required fields", async () => {
render(
<AuthProvider>
<RegisterForm />
</AuthProvider>
);
await waitFor(() => {
// Check for username field
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
// Check for password field
expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument();
});
});
it("renders switch to login link", async () => {
const onSwitchToLogin = vi.fn();
render(
<AuthProvider>
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
</AuthProvider>
);
await waitFor(() => {
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
expect(loginLink).toBeInTheDocument();
});
});
it("calls onSwitchToLogin when clicked", async () => {
const onSwitchToLogin = vi.fn();
render(
<AuthProvider>
<RegisterForm onSwitchToLogin={onSwitchToLogin} />
</AuthProvider>
);
await waitFor(() => {
const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i);
fireEvent.click(loginLink);
});
expect(onSwitchToLogin).toHaveBeenCalled();
});
it("shows password mismatch error and does not submit", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
authEnabled: true,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: false,
needsSetup: true,
oidcProviderName: "",
}),
});
render(
<AuthProvider>
<RegisterForm />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: "new-user" } });
fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: "password123" } });
fireEvent.change(screen.getByLabelText(/auth\.confirmPassword/i), { target: { value: "different123" } });
fireEvent.click(screen.getByRole("button", { name: /auth\.register/i }));
await waitFor(() => {
expect(screen.getByText(/auth\.passwordMismatch/i)).toBeInTheDocument();
});
expect(fetch).not.toHaveBeenCalledWith("/api/auth/register", expect.objectContaining({ method: "POST" }));
});
});
describe("AuthPage", () => {
const mockAuthState = {
authEnabled: true,
localAuthEnabled: true,
oidcEnabled: false,
registrationEnabled: true,
hasUsers: true,
needsSetup: false,
oidcProviderName: "",
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockAuthState),
})
.mockResolvedValueOnce({
status: 401,
ok: false,
});
});
it("renders login form by default", async () => {
render(
<AuthProvider>
<AuthPage />
</AuthProvider>
);
await waitFor(() => {
// Should show login form with username field
expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument();
});
});
it("switches to register mode when create account is clicked", async () => {
render(
<AuthProvider>
<AuthPage />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /auth\.createAccount/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /auth\.createAccount/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: /^auth\.register$/i })).toBeInTheDocument();
});
});
});
describe("UserProfile", () => {
const mockUser = {
id: 1,
username: "testuser",
avatarUrl: null,
};
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
});
});
it("renders user profile when user is logged in", async () => {
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("testuser")).toBeInTheDocument();
});
});
it("displays user avatar initial when no avatar", async () => {
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
// The avatar shows first letter of username
expect(screen.getByText("T")).toBeInTheDocument();
});
});
it("renders change password section", async () => {
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText(/auth\.changePassword/i)).toBeInTheDocument();
});
});
it("renders cancel button that calls onClose", async () => {
const onClose = vi.fn();
render(
<AuthProvider>
<UserProfile onClose={onClose} />
</AuthProvider>
);
await waitFor(() => {
const cancelBtn = screen.getByText(/common\.close/i);
fireEvent.click(cancelBtn);
});
expect(onClose).toHaveBeenCalled();
});
it("shows password mismatch error on update", async () => {
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("testuser")).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/auth\.newPassword/i), { target: { value: "new-password-123" } });
fireEvent.change(screen.getByLabelText(/auth\.confirmPassword/i), { target: { value: "different-password" } });
const submitButton = screen.getByRole("button", { name: /auth\.updatePassword/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/auth\.passwordMismatch/i)).toBeInTheDocument();
});
});
it("opens delete confirmation and executes account deletion", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("testuser")).toBeInTheDocument();
});
const dangerButtons = screen.getAllByRole("button", { name: /auth\.deleteAccount/i });
fireEvent.click(dangerButtons[0]);
await waitFor(() => {
expect(screen.getByRole("button", { name: /auth\.deleteAccountButton/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /auth\.deleteAccountButton/i }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
"/api/auth/me",
expect.objectContaining({ method: "DELETE", credentials: "include" })
);
});
});
it("closes profile on Escape key when onClose is provided", async () => {
const onClose = vi.fn();
render(
<AuthProvider>
<UserProfile onClose={onClose} />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("testuser")).toBeInTheDocument();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("shows delete error when account deletion fails", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: "Delete failed badly" }),
});
render(
<AuthProvider>
<UserProfile />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("testuser")).toBeInTheDocument();
});
const dangerButtons = screen.getAllByRole("button", { name: /auth\.deleteAccount/i });
fireEvent.click(dangerButtons[0]);
await waitFor(() => {
expect(screen.getByRole("button", { name: /auth\.deleteAccountButton/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /auth\.deleteAccountButton/i }));
await waitFor(() => {
expect(screen.getAllByText("Delete failed badly").length).toBeGreaterThan(0);
});
});
});
describe("AuthProvider methods", () => {
it("register performs auto-login and refreshes auth state", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ user: { id: 2, username: "newuser" } }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.register("newuser", "secure-password-123");
});
expect(fetch).toHaveBeenCalledWith(
"/api/auth/register",
expect.objectContaining({ method: "POST", credentials: "include" })
);
expect(fetch).toHaveBeenCalledWith(
"/api/auth/login",
expect.objectContaining({ method: "POST", credentials: "include" })
);
});
it("logout clears current user", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ user: { id: 3, username: "logout-user" } }) })
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.login("logout-user", "pw");
});
expect(result.current.user?.username).toBe("logout-user");
await act(async () => {
await result.current.logout();
});
expect(result.current.user).toBeNull();
});
it("refreshUser retries after token refresh on 401", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, localAuthEnabled: true }) })
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "refreshed-user" }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.refreshUser();
});
await waitFor(() => {
expect(result.current.user?.username).toBe("refreshed-user");
});
});
it("login throws backend error message on failed login", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Invalid credentials" }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await expect(result.current.login("user", "bad-password")).rejects.toThrow("Invalid credentials");
});
it("updateProfile sends PUT and refreshes user data", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "updated-user" }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.updateProfile({ currentPassword: "old", newPassword: "new-password-123" });
});
expect(fetch).toHaveBeenCalledWith(
"/api/auth/me",
expect.objectContaining({ method: "PUT", credentials: "include" })
);
expect(result.current.user?.username).toBe("updated-user");
});
it("uploadAvatar posts FormData and refreshes user", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "avatar-user" }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
await act(async () => {
await result.current.uploadAvatar(file);
});
expect(fetch).toHaveBeenCalledWith(
"/api/auth/avatar",
expect.objectContaining({ method: "POST", credentials: "include" })
);
expect(result.current.user?.username).toBe("avatar-user");
});
it("deleteAvatar throws backend error on failure", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: "Delete failed" }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await expect(result.current.deleteAvatar()).rejects.toThrow("Delete failed");
});
it("authFetch does not refresh token for auth endpoints", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: false, status: 401 });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const response = await result.current.authFetch("/api/auth/me", { method: "GET" });
expect(response.status).toBe(401);
const refreshCalls = (fetch as ReturnType<typeof vi.fn>).mock.calls.filter(
(call) => call[0] === "/api/auth/refresh"
);
expect(refreshCalls.length).toBe(0);
});
it("refreshUser clears user when /auth/me returns non-401 error", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: false, status: 500 });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.refreshUser();
});
expect(result.current.user).toBeNull();
});
it("updateProfile throws default message when backend has no error field", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await expect(result.current.updateProfile({ currentPassword: "a", newPassword: "b" })).rejects.toThrow(
"Update failed"
);
});
it("uploadAvatar throws default message when error payload is invalid JSON", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({
ok: false,
json: () => Promise.reject(new Error("invalid json")),
});
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
await expect(result.current.uploadAvatar(file)).rejects.toThrow("Upload failed");
});
it("deleteAvatar succeeds and refreshes user", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, username: "avatar-deleted" }) });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.deleteAvatar();
});
expect(result.current.user?.username).toBe("avatar-deleted");
});
it("deleteAccount clears current user on success", async () => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ user: { id: 9, username: "to-delete" } }) })
.mockResolvedValueOnce({ ok: true });
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
await result.current.login("to-delete", "pw");
});
expect(result.current.user?.username).toBe("to-delete");
await act(async () => {
await result.current.deleteAccount();
});
expect(result.current.user).toBeNull();
});
});