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).mockReturnValue( mockQuery({ data: users }) ); (createAdminInvitesQuery as ReturnType).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).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).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).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).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).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).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).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).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).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).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); }); });