Files
TREK/client/tests/unit/mobile/admin/MAdminSheets.test.tsx
T
Maurice de128cc4ae wip(i18n): plural forms through Intl.PluralRules
Unfinished, continued in the next session: t() resolves key.one/.few/...
by the language's own plural rule, the parity check knows which forms a
language needs, 16 singular/plural key pairs are merged in every locale
and every count === 1 branch in the client uses the plural form. Still
open: the forms each locale needs for the remaining count strings, the
two new labels in every locale, and 35 tests that expect the old English
wording.
2026-10-07 22:57:51 +02:00

431 lines
17 KiB
TypeScript

// FE-MOB-ASHEET-001 to FE-MOB-ASHEET-024
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores } from '../../../helpers/store';
import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../helpers/mobileAdmin';
import { useTranslation } from '../../../../src/i18n';
import MAdminSheets from '../../../../src/mobile/screens/admin/MAdminSheets';
function Harness({ admin }: { admin: AdminHook }) {
const { t } = useTranslation();
return <MAdminSheets admin={admin} t={t} />;
}
type Spy = ReturnType<typeof vi.fn>;
type Spies = Record<string, Spy> & { toast: Record<string, Spy> };
function renderSheets(overrides: Record<string, unknown> = {}) {
const admin = buildAdminHook(overrides);
render(<Harness admin={admin} />);
return admin as unknown as Spies;
}
interface UserForm {
username: string;
email: string;
role: string;
password: string;
}
/**
* The form inputs read e.target.value inside the state updater, so a spy setter
* can't be inspected afterwards — React has already restored the controlled
* value by then. These sheets therefore get real state.
*/
function StatefulSheets({
initialForm,
overrides,
}: {
initialForm: UserForm;
overrides: Record<string, unknown>;
}) {
const { t } = useTranslation();
const [form, setForm] = React.useState<UserForm>(initialForm);
const admin = buildAdminHook({
...overrides,
createForm: form,
setCreateForm: setForm,
editForm: form,
setEditForm: setForm,
});
return <MAdminSheets admin={admin} t={t} />;
}
const ME = buildAdminUser({ id: 1, username: 'admin' });
const ALICE = buildAdminUser({ id: 2, username: 'alice', email: 'alice@example.com', role: 'user' });
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('MAdminSheets', () => {
it('FE-MOB-ASHEET-001: nothing is rendered while every sheet is closed', () => {
renderSheets();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-MOB-ASHEET-002: the create sheet shows the four fields prefilled from createForm', () => {
renderSheets({
showCreateUser: true,
createForm: { username: 'newbie', email: 'new@example.com', password: 'secret12', role: 'admin' },
});
expect(screen.getByRole('dialog', { name: 'Create User' })).toBeInTheDocument();
expect(screen.getByPlaceholderText('Username')).toHaveValue('newbie');
expect(screen.getByPlaceholderText('Email')).toHaveValue('new@example.com');
expect(screen.getByPlaceholderText('Password')).toHaveValue('secret12');
expect(screen.getByRole('tab', { name: 'Administrator' })).toHaveAttribute('aria-selected', 'true');
});
it('FE-MOB-ASHEET-003: each create-form input writes through to its own field', async () => {
const user = userEvent.setup();
render(
<StatefulSheets
initialForm={{ username: '', email: '', role: 'user', password: '' }}
overrides={{ showCreateUser: true }}
/>,
);
await user.type(screen.getByPlaceholderText('Username'), 'newbie');
await user.type(screen.getByPlaceholderText('Email'), 'new@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'secret12');
expect(screen.getByPlaceholderText('Username')).toHaveValue('newbie');
expect(screen.getByPlaceholderText('Email')).toHaveValue('new@example.com');
expect(screen.getByPlaceholderText('Password')).toHaveValue('secret12');
});
it('FE-MOB-ASHEET-003b: the password eye toggles the input type', async () => {
const user = userEvent.setup();
renderSheets({ showCreateUser: true });
const input = screen.getByPlaceholderText('Password');
expect(input).toHaveAttribute('type', 'password');
await user.click(screen.getByRole('button', { name: 'Show or hide' }));
expect(screen.getByPlaceholderText('Password')).toHaveAttribute('type', 'text');
});
it('FE-MOB-ASHEET-004: the role segment writes the picked role', async () => {
const user = userEvent.setup();
const admin = renderSheets({ showCreateUser: true });
await user.click(screen.getByRole('tab', { name: 'Administrator' }));
const updater = admin.setCreateForm.mock.calls[0][0] as (f: Record<string, string>) => Record<string, string>;
expect(updater({ username: 'a', email: 'b', password: 'c', role: 'user' }).role).toBe('admin');
});
it('FE-MOB-ASHEET-005: create sheet footer submits and cancels', async () => {
const user = userEvent.setup();
const admin = renderSheets({ showCreateUser: true });
await user.click(screen.getByRole('button', { name: 'Create User' }));
expect(admin.handleCreateUser).toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(admin.setShowCreateUser).toHaveBeenCalledWith(false);
});
it('FE-MOB-ASHEET-006: the edit sheet only renders its body once a user is being edited', () => {
renderSheets({
editingUser: ALICE,
editForm: { username: 'alice', email: 'alice@example.com', role: 'user', password: '' },
});
expect(screen.getByRole('dialog', { name: 'Edit User' })).toBeInTheDocument();
expect(screen.getByDisplayValue('alice')).toBeInTheDocument();
expect(screen.getByDisplayValue('alice@example.com')).toBeInTheDocument();
expect(screen.getByText('Reset passkeys')).toBeInTheDocument();
});
it('FE-MOB-ASHEET-007: the edit form writes username, email, password and role separately', async () => {
const user = userEvent.setup();
render(
<StatefulSheets
initialForm={{ username: 'alice', email: 'alice@example.com', role: 'user', password: '' }}
overrides={{ editingUser: ALICE }}
/>,
);
await user.type(screen.getByDisplayValue('alice'), 'x');
await user.type(screen.getByDisplayValue('alice@example.com'), '.uk');
await user.type(screen.getByPlaceholderText('Enter new password…'), 'newsecret');
await user.click(screen.getByRole('tab', { name: 'Administrator' }));
expect(screen.getByDisplayValue('alicex')).toBeInTheDocument();
expect(screen.getByDisplayValue('alice@example.com.uk')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter new password…')).toHaveValue('newsecret');
expect(screen.getByRole('tab', { name: 'Administrator' })).toHaveAttribute('aria-selected', 'true');
});
it('FE-MOB-ASHEET-008: saving and cancelling the edit sheet call through', async () => {
const user = userEvent.setup();
const admin = renderSheets({ editingUser: ALICE });
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(admin.handleSaveUser).toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(admin.setEditingUser).toHaveBeenCalledWith(null);
});
it('FE-MOB-ASHEET-009: deleting closes the sheet first and then deletes that user', async () => {
const user = userEvent.setup();
const admin = renderSheets({ editingUser: ALICE, currentUser: ME });
const deleteBtn = screen.getByRole('button', { name: 'Delete user' });
expect(deleteBtn).not.toBeDisabled();
await user.click(deleteBtn);
expect(admin.setEditingUser).toHaveBeenCalledWith(null);
expect(admin.handleDeleteUser).toHaveBeenCalledWith(ALICE);
});
it('FE-MOB-ASHEET-010: the delete button is disabled for the signed-in admin', () => {
renderSheets({ editingUser: ME, currentUser: ME });
expect(screen.getByRole('button', { name: 'Delete user' })).toBeDisabled();
});
it('FE-MOB-ASHEET-011: resetting passkeys confirms, calls the API and reports the count', async () => {
const user = userEvent.setup();
let hitId = '';
server.use(
http.delete('/api/admin/users/:id/passkeys', ({ params }) => {
hitId = String(params.id);
return HttpResponse.json({ deleted: 3 });
}),
);
const admin = renderSheets({ editingUser: ALICE });
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
const confirm = screen.getByRole('dialog', { name: 'Reset passkeys' });
expect(confirm).toBeInTheDocument();
expect(screen.getByText('Remove all passkeys for alice?')).toBeInTheDocument();
await user.click(screen.getAllByRole('button', { name: 'Reset passkeys' })[1]);
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkeys'));
expect(hitId).toBe('2');
});
it('FE-MOB-ASHEET-012: a failing passkey reset toasts the error and keeps the sheet open', async () => {
const user = userEvent.setup();
server.use(
http.delete('/api/admin/users/:id/passkeys', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
const admin = renderSheets({ editingUser: ALICE });
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
await user.click(screen.getAllByRole('button', { name: 'Reset passkeys' })[1]);
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
expect(screen.getByRole('dialog', { name: 'Reset passkeys' })).toBeInTheDocument();
});
it('FE-MOB-ASHEET-013: the passkey confirm can be dismissed without calling the API', async () => {
const user = userEvent.setup();
let called = false;
server.use(
http.delete('/api/admin/users/:id/passkeys', () => {
called = true;
return HttpResponse.json({ deleted: 0 });
}),
);
renderSheets({ editingUser: ALICE });
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]);
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(),
);
expect(called).toBe(false);
});
it('FE-MOB-ASHEET-014: the update sheet shows the docker commands for a docker install', () => {
renderSheets({
showUpdateModal: true,
updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true },
});
expect(screen.getByRole('dialog', { name: 'How to Update' })).toBeInTheDocument();
expect(screen.getByText('v3.4.0 → v3.5.0')).toBeInTheDocument();
expect(screen.getByText(/docker pull mauriceboe\/trek:latest/)).toBeInTheDocument();
expect(
screen.getByText(
'Your TREK instance runs in Docker. To update to v3.5.0, run the following commands on your server:',
),
).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /update guide/ })).not.toBeInTheDocument();
});
it('FE-MOB-ASHEET-015: a non-docker install links the wiki guide instead', () => {
renderSheets({
showUpdateModal: true,
updateInfo: {
update_available: true,
current: '3.4.0',
latest: '3.5.0',
is_docker: false,
release_url: 'https://github.com/liketrek/TREK/releases/v3.5.0',
},
});
expect(screen.getByRole('link', { name: 'Open the update guide' })).toHaveAttribute(
'href',
'https://github.com/liketrek/TREK/wiki/Updating',
);
expect(screen.queryByText(/docker pull/)).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'View on GitHub' })).toHaveAttribute(
'href',
'https://github.com/liketrek/TREK/releases/v3.5.0',
);
});
it('FE-MOB-ASHEET-016: the update sheet closes from its footer', async () => {
const user = userEvent.setup();
const admin = renderSheets({
showUpdateModal: true,
updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true },
});
// Header icon button and footer button both close the sheet
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
expect(closeButtons).toHaveLength(2);
await user.click(closeButtons[1]);
expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false);
await user.click(closeButtons[0]);
expect(admin.setShowUpdateModal).toHaveBeenCalledTimes(2);
});
it('FE-MOB-ASHEET-017: rotating the JWT secret logs the admin out and redirects', async () => {
const user = userEvent.setup();
let rotated = false;
server.use(
http.post('/api/admin/rotate-jwt-secret', () => {
rotated = true;
return HttpResponse.json({ success: true });
}),
);
const admin = renderSheets({ showRotateJwtModal: true });
expect(screen.getByRole('dialog', { name: 'Rotate JWT Secret' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Rotate & Log out' }));
await waitFor(() => expect(admin.logout).toHaveBeenCalled());
expect(rotated).toBe(true);
expect(admin.setRotatingJwt).toHaveBeenCalledWith(true);
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false);
expect(admin.navigate).toHaveBeenCalledWith('/login', { state: { noRedirect: true } });
});
it('FE-MOB-ASHEET-018: a failing rotation toasts and leaves the session alone', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/admin/rotate-jwt-secret', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
const admin = renderSheets({ showRotateJwtModal: true });
await user.click(screen.getByRole('button', { name: 'Rotate & Log out' }));
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error'));
expect(admin.logout).not.toHaveBeenCalled();
expect(admin.setRotatingJwt).toHaveBeenLastCalledWith(false);
});
it('FE-MOB-ASHEET-019: Escape and the header X close the rotate sheet', async () => {
const user = userEvent.setup();
const admin = renderSheets({ showRotateJwtModal: true });
fireEvent.keyDown(document, { key: 'Escape' });
expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false);
await user.click(screen.getByRole('button', { name: 'Close' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(admin.setShowRotateJwtModal).toHaveBeenCalledTimes(3);
});
it('FE-MOB-ASHEET-020: Escape and the header X close the create sheet', async () => {
const user = userEvent.setup();
const admin = renderSheets({ showCreateUser: true });
fireEvent.keyDown(document, { key: 'Escape' });
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(admin.setShowCreateUser).toHaveBeenCalledTimes(2);
expect(admin.setShowCreateUser).toHaveBeenCalledWith(false);
});
it('FE-MOB-ASHEET-021: Escape and the header X close the edit sheet', async () => {
const user = userEvent.setup();
const admin = renderSheets({ editingUser: ALICE });
fireEvent.keyDown(document, { key: 'Escape' });
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(admin.setEditingUser).toHaveBeenCalledTimes(2);
expect(admin.setEditingUser).toHaveBeenCalledWith(null);
});
it('FE-MOB-ASHEET-022: Escape closes the update sheet', () => {
const admin = renderSheets({
showUpdateModal: true,
updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true },
});
fireEvent.keyDown(document, { key: 'Escape' });
expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false);
});
it('FE-MOB-ASHEET-023: the passkey sheet closes from Escape and from its header X', async () => {
const user = userEvent.setup();
renderSheets({ editingUser: ALICE });
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(),
);
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
// Second dialog in the DOM is the confirm on top of the edit sheet
await user.click(screen.getAllByRole('button', { name: 'Close' })[1]);
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(),
);
});
it('FE-MOB-ASHEET-024: confirming the passkey reset without an edited user does nothing', async () => {
const user = userEvent.setup();
let called = false;
server.use(
http.delete('/api/admin/users/:id/passkeys', () => {
called = true;
return HttpResponse.json({ deleted: 0 });
}),
);
const admin = buildAdminHook({ editingUser: ALICE });
const { rerender } = render(<Harness admin={admin} />);
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
// The edit sheet closes underneath while the confirm stays open
rerender(<Harness admin={buildAdminHook({ editingUser: null })} />);
await user.click(screen.getByRole('button', { name: 'Reset passkeys' }));
expect(called).toBe(false);
expect(screen.getByRole('dialog', { name: 'Reset passkeys' })).toBeInTheDocument();
});
});