import React from "react"; import "@testing-library/jest-dom"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { CssVarsProvider } from "@mui/material/styles"; import { api } from "./api"; import { ConfirmProvider } from "./confirm"; import { I18nProvider } from "./i18n/I18nProvider"; import { PromptProvider } from "./prompt"; import { getTheme } from "./theme"; import { ToastProvider } from "./toast"; import AdminUsersPage from "./views/AdminUsersPage"; jest.mock("./api", () => ({ api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() }, getApiErrorMessage: (error: any, fallback: string) => error?.response?.data?.detail || fallback, })); const mockedApi = api as jest.Mocked; function renderPage(users: unknown[]) { mockedApi.get.mockResolvedValue({ data: users } as any); mockedApi.put.mockResolvedValue({ data: null } as any); mockedApi.delete.mockResolvedValue({ data: null } as any); return render( , ); } beforeEach(() => { jest.clearAllMocks(); mockedApi.get.mockReset(); mockedApi.post.mockReset(); window.localStorage.clear(); Object.defineProperty(window, "matchMedia", { configurable: true, value: jest.fn().mockImplementation(() => ({ matches: true, addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn(), })), }); }); function held() { let resolve!: (value: T) => void; const promise=new Promise(yes => { resolve=yes; }); return { promise, resolve }; } function createDraft(email='new@example.test') { fireEvent.change(screen.getByLabelText('Email'), { target: { value: email } }); fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'TestPassword123!' } }); } test.each([['en', 'Rows per page:'], ['nb', 'Rader per side:']])('desktop user-grid pagination follows %s UI language', async (language, label) => { window.localStorage.setItem('uiLanguage', language); (window.matchMedia as jest.Mock).mockImplementation(() => ({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn() })); renderPage([{ id: 'locale-user', email: 'locale@example.test', roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); expect(await screen.findByText(label)).toBeInTheDocument(); expect(screen.getByLabelText(label)).toHaveTextContent('10'); expect(mockedApi.get).toHaveBeenCalledTimes(1); }); test('failed users read stays unavailable and Retry only reads', async () => { mockedApi.get.mockRejectedValueOnce(new Error('offline')); renderPage([]); const retry=await screen.findByRole('button', { name: 'Retry' }); expect(screen.queryByText('No users.')).not.toBeInTheDocument(); fireEvent.click(retry); expect(await screen.findByText('No users.')).toBeInTheDocument(); expect(mockedApi.get).toHaveBeenCalledTimes(2); expect(mockedApi.post).not.toHaveBeenCalled(); }); test('create failure presents structured feedback and retains the entered fields', async () => { mockedApi.post.mockRejectedValue({ response: { status: 400, data: { detail: 'The email is already registered.' } } }); renderPage([]); await screen.findByText('No users.'); createDraft(); fireEvent.click(screen.getByRole('button', { name: 'Create' })); expect(await screen.findByText('The email is already registered.')).toBeInTheDocument(); expect(screen.getByLabelText('Email')).toHaveValue('new@example.test'); }); test('create admission serializes competing clicks and acknowledgement retains newer form values', async () => { const pending=held(); mockedApi.post.mockReturnValue(pending.promise); renderPage([]); await screen.findByText('No users.'); createDraft(); const create=screen.getByRole('button', { name: 'Create' }); act(() => { create.click(); create.click(); }); expect(mockedApi.post).toHaveBeenCalledTimes(1); fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'next@example.test' } }); await act(async () => pending.resolve({ data: { id: 'created' } })); expect(screen.getByLabelText('Email')).toHaveValue('next@example.test'); }); test('successful create followed by failed list refresh keeps read recovery without replaying create', async () => { mockedApi.post.mockResolvedValue({ data: { id: 'created' } }); renderPage([]); await screen.findByText('No users.'); mockedApi.get.mockRejectedValueOnce(new Error('offline')); createDraft(); fireEvent.click(screen.getByRole('button', { name: 'Create' })); const retry=await screen.findByRole('button', { name: 'Retry' }); fireEvent.click(retry); await screen.findByText('No users.'); expect(mockedApi.post).toHaveBeenCalledTimes(1); }); test('disposed create acknowledgement cannot start a new private list read', async () => { const pending=held(); mockedApi.post.mockReturnValue(pending.promise); const view=renderPage([]); await screen.findByText('No users.'); createDraft(); fireEvent.click(screen.getByRole('button', { name: 'Create' })); view.unmount(); await act(async () => pending.resolve({ data: { id: 'created' } })); expect(mockedApi.get).toHaveBeenCalledTimes(1); }); test("requires explicit confirmation before removing your own admin role", async () => { renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: true }]); expect(screen.getByRole("heading", { name: "Users", level: 2 })).toBeInTheDocument(); fireEvent.click(await screen.findByRole("button", { name: "Remove admin" })); expect(await screen.findByText(/you will immediately lose access/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(mockedApi.put).not.toHaveBeenCalled(); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "Remove admin" })); fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Remove admin" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith("/users/me/roles", { roles: [] })); }); test("warns before demoting another administrator", async () => { renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: ["Admin"], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); fireEvent.click(await screen.findByRole("button", { name: "Remove admin" })); expect(await screen.findByText(/they will immediately lose access/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(mockedApi.put).not.toHaveBeenCalled(); }); test("disables demotion and deletion for the final administrator", async () => { renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: false }]); const demote = await screen.findByRole("button", { name: "Remove admin" }); expect(demote).toBeDisabled(); expect(demote.parentElement).not.toHaveAttribute("aria-label"); fireEvent.mouseOver(demote.parentElement!); expect(await screen.findByRole("tooltip")).toHaveTextContent("The final administrator cannot be demoted or deleted."); expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); }); test("sends the exact account email after confirming deletion", async () => { renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); fireEvent.click(await screen.findByRole("button", { name: "Delete" })); fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete" })); await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/users/other", { headers: { "X-Confirm-Account-Deletion": "other@example.com" }, })); }); test('accepted deletion is reported as queued rather than completed', async () => { renderPage([{ id: 'other', email: 'other@example.com', userName: 'other', roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); mockedApi.delete.mockResolvedValueOnce({ status: 202, data: { status: 'pending' } } as any); fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' })); expect(await screen.findByText('Account deletion request queued. Deletion has not completed yet.')).toBeInTheDocument(); expect(screen.queryByText('User deleted.')).not.toBeInTheDocument(); });