feat(admin): migrate Users with safe recovery and action state
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
import { api } from "./api";
|
||||
@@ -13,7 +13,7 @@ import AdminUsersPage from "./views/AdminUsersPage";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), put: jest.fn(), post: jest.fn(), delete: jest.fn() },
|
||||
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
|
||||
getApiErrorMessage: (error: any, fallback: string) => error?.response?.data?.detail || fallback,
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
@@ -22,7 +22,7 @@ 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);
|
||||
render(
|
||||
return render(
|
||||
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
@@ -39,6 +39,7 @@ function renderPage(users: unknown[]) {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockReset(); mockedApi.post.mockReset(); window.localStorage.clear();
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
@@ -51,6 +52,38 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function held<T>() { let resolve!: (value: T) => void; const promise=new Promise<T>(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('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<any>(); 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<any>(); 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 }]);
|
||||
|
||||
@@ -96,3 +129,12 @@ test("sends the exact account email after confirming deletion", async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user