bffa5b28bd
- DiagnosticsReporter.collectServerHealth: drop distinctUntilChanged() on networkStatus.state (StateFlow is already distinct; the deprecation warning is a hard error under allWarningsAsErrors). - web users.test.ts: add debug_mode_enabled to the alice/bob AdminUser fixtures now that the field is required on the type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
211 lines
7.9 KiB
TypeScript
211 lines
7.9 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
import { mockQuery } from '../../../test-utils/query';
|
|
import type { AdminUser, AdminInvite } from '$lib/api/admin';
|
|
|
|
// Wrap useQueryClient so the page can call invalidateQueries() without a real
|
|
// QueryClient context. Everything else from svelte-query passes through.
|
|
vi.mock('$lib/api/admin', () => ({
|
|
createAdminUsersQuery: vi.fn(),
|
|
createAdminInvitesQuery: vi.fn(),
|
|
listUsers: vi.fn(),
|
|
updateUserAdmin: vi.fn(),
|
|
listInvites: vi.fn(),
|
|
createInvite: vi.fn(),
|
|
deleteInvite: vi.fn(),
|
|
createUser: vi.fn(),
|
|
deleteUser: vi.fn(),
|
|
resetUserPassword: vi.fn(),
|
|
updateUserAutoApprove: vi.fn()
|
|
}));
|
|
|
|
import AdminUsersPage from './+page.svelte';
|
|
import {
|
|
createAdminUsersQuery,
|
|
createAdminInvitesQuery,
|
|
updateUserAdmin,
|
|
createInvite,
|
|
deleteInvite,
|
|
createUser,
|
|
deleteUser,
|
|
resetUserPassword,
|
|
updateUserAutoApprove
|
|
} from '$lib/api/admin';
|
|
|
|
const alice: AdminUser = {
|
|
id: 'u1',
|
|
username: 'alice',
|
|
display_name: null,
|
|
is_admin: true,
|
|
auto_approve_requests: false,
|
|
debug_mode_enabled: false,
|
|
created_at: '2026-05-01T00:00:00Z'
|
|
};
|
|
|
|
const bob: AdminUser = {
|
|
id: 'u2',
|
|
username: 'bob',
|
|
display_name: 'Bob B',
|
|
is_admin: false,
|
|
auto_approve_requests: false,
|
|
debug_mode_enabled: false,
|
|
created_at: '2026-05-02T00:00:00Z'
|
|
};
|
|
|
|
const sampleInvite: AdminInvite = {
|
|
token: 'tok-abc123',
|
|
invited_by: 'u1',
|
|
note: null,
|
|
created_at: '2026-05-06T10:00:00Z',
|
|
expires_at: '2026-05-07T10:00:00Z',
|
|
redeemed_at: null,
|
|
redeemed_by: null
|
|
};
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
function setup(users: AdminUser[] = [alice, bob], invites: AdminInvite[] = []) {
|
|
(createAdminUsersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockQuery({ data: users })
|
|
);
|
|
(createAdminInvitesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockQuery({ data: invites })
|
|
);
|
|
return render(AdminUsersPage);
|
|
}
|
|
|
|
describe('/admin/users', () => {
|
|
test('renders the users list with admin badge', () => {
|
|
setup();
|
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
|
expect(screen.getByText('bob')).toBeInTheDocument();
|
|
expect(screen.getByTestId('admin-badge')).toHaveTextContent('admin');
|
|
});
|
|
|
|
test('empty state shows fallback copy', () => {
|
|
setup([]);
|
|
expect(screen.getByText('No users yet.')).toBeInTheDocument();
|
|
});
|
|
|
|
test('Make admin button calls updateUserAdmin(id, true)', async () => {
|
|
(updateUserAdmin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...bob, is_admin: true });
|
|
setup();
|
|
await fireEvent.click(screen.getByRole('button', { name: /make bob admin/i }));
|
|
expect(updateUserAdmin).toHaveBeenCalledWith('u2', true);
|
|
});
|
|
|
|
test('Remove admin button calls updateUserAdmin(id, false)', async () => {
|
|
(updateUserAdmin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...alice, is_admin: false });
|
|
setup();
|
|
await fireEvent.click(screen.getByRole('button', { name: /remove admin from alice/i }));
|
|
expect(updateUserAdmin).toHaveBeenCalledWith('u1', false);
|
|
});
|
|
|
|
test('last_admin error surfaces correct toast', async () => {
|
|
(updateUserAdmin as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'last_admin' });
|
|
setup();
|
|
await fireEvent.click(screen.getByRole('button', { name: /remove admin from alice/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId('toast')).toHaveTextContent(/last admin/i)
|
|
);
|
|
});
|
|
|
|
test('Generate invite button calls createInvite', async () => {
|
|
(createInvite as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sampleInvite);
|
|
setup();
|
|
await fireEvent.click(screen.getByRole('button', { name: /generate invite/i }));
|
|
expect(createInvite).toHaveBeenCalled();
|
|
});
|
|
|
|
test('invite list renders token and Revoke button', () => {
|
|
setup([alice, bob], [sampleInvite]);
|
|
expect(screen.getByText('tok-abc123')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /revoke invite tok-abc123/i })).toBeInTheDocument();
|
|
});
|
|
|
|
test('Revoke button calls deleteInvite with the token', async () => {
|
|
(deleteInvite as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
|
|
setup([alice, bob], [sampleInvite]);
|
|
await fireEvent.click(screen.getByRole('button', { name: /revoke invite tok-abc123/i }));
|
|
expect(deleteInvite).toHaveBeenCalledWith('tok-abc123');
|
|
});
|
|
|
|
test('empty invite state shows fallback copy', () => {
|
|
setup([alice], []);
|
|
expect(screen.getByText('No active invites.')).toBeInTheDocument();
|
|
});
|
|
|
|
test('opens new-user modal and submits createUser on form submission', async () => {
|
|
(createUser as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
|
setup();
|
|
await waitFor(() => screen.getByText('alice'));
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: /New user/i }));
|
|
await waitFor(() => screen.getByRole('dialog'));
|
|
|
|
await fireEvent.input(screen.getByLabelText('Username'), { target: { value: 'created1' } });
|
|
const passwordInputs = screen.getAllByLabelText(/^Password$/i);
|
|
await fireEvent.input(passwordInputs[0], { target: { value: 'abcd1234' } });
|
|
await fireEvent.input(screen.getByLabelText(/Confirm password/i), { target: { value: 'abcd1234' } });
|
|
await fireEvent.click(screen.getByRole('button', { name: /^Create user$/i }));
|
|
|
|
expect(createUser).toHaveBeenCalledWith(expect.objectContaining({
|
|
username: 'created1',
|
|
password: 'abcd1234',
|
|
}));
|
|
});
|
|
|
|
test('opens delete confirm and calls deleteUser on confirm', async () => {
|
|
(deleteUser as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
setup();
|
|
await waitFor(() => screen.getByText('alice'));
|
|
|
|
// Row buttons carry per-user aria-labels ("Delete alice"). The modal's
|
|
// confirm button has no aria-label so its accessible name is "Delete" exactly.
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Delete alice' }));
|
|
await waitFor(() => screen.getByRole('dialog'));
|
|
await fireEvent.click(screen.getByRole('button', { name: /^Delete$/ }));
|
|
|
|
expect(deleteUser).toHaveBeenCalled();
|
|
});
|
|
|
|
test('last_admin error from delete shows clear toast', async () => {
|
|
(deleteUser as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'last_admin' });
|
|
setup();
|
|
await waitFor(() => screen.getByText('alice'));
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Delete alice' }));
|
|
await waitFor(() => screen.getByRole('dialog'));
|
|
await fireEvent.click(screen.getByRole('button', { name: /^Delete$/ }));
|
|
|
|
await waitFor(() => screen.getByRole('status'));
|
|
expect(screen.getByRole('status').textContent).toMatch(/last admin/i);
|
|
});
|
|
|
|
test('reset-password modal calls resetUserPassword with the new password', async () => {
|
|
(resetUserPassword as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
setup();
|
|
await waitFor(() => screen.getByText('alice'));
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: 'Reset password for alice' }));
|
|
await waitFor(() => screen.getByRole('dialog'));
|
|
|
|
await fireEvent.input(screen.getByLabelText(/^New password$/i), { target: { value: 'abcd1234' } });
|
|
await fireEvent.input(screen.getByLabelText(/^Confirm$/i), { target: { value: 'abcd1234' } });
|
|
// /^Set password$/ avoids matching the row's "Reset password" buttons
|
|
// (regex `Set password` matches "ReSet password" case-insensitively).
|
|
await fireEvent.click(screen.getByRole('button', { name: /^Set password$/i }));
|
|
|
|
expect(resetUserPassword).toHaveBeenCalledWith(expect.any(String), 'abcd1234');
|
|
});
|
|
|
|
test('toggle-auto-approve calls updateUserAutoApprove', async () => {
|
|
(updateUserAutoApprove as ReturnType<typeof vi.fn>).mockResolvedValue({});
|
|
setup();
|
|
await waitFor(() => screen.getByText('alice'));
|
|
|
|
await fireEvent.click(screen.getAllByRole('button', { name: /Enable auto-approve/i })[0]);
|
|
expect(updateUserAutoApprove).toHaveBeenCalledWith(expect.any(String), true);
|
|
});
|
|
});
|