Files
TREK/client/src/pages/admin/useAdmin.test.tsx
T

983 lines
38 KiB
TypeScript

// FE-ADMHOOK-001 to FE-ADMHOOK-058
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router';
import { buildAdmin, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { TranslationProvider } from '../../i18n/TranslationContext';
import { adminApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { useAdmin } from './useAdmin';
const toastCalls: { type: string; message: string }[] = [];
vi.mock('../../components/shared/Toast', () => ({
useToast: () => ({
success: (m: string) => toastCalls.push({ type: 'success', message: m }),
error: (m: string) => toastCalls.push({ type: 'error', message: m }),
warning: (m: string) => toastCalls.push({ type: 'warning', message: m }),
info: (m: string) => toastCalls.push({ type: 'info', message: m }),
}),
}));
function wrapper({ children }: { children: React.ReactNode }) {
return (
<MemoryRouter>
<TranslationProvider>{children}</TranslationProvider>
</MemoryRouter>
);
}
/** Mounts the hook and waits until the initial parallel loads have settled. */
async function mountAdmin(url = '/admin') {
const at = ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={[url]}>
<TranslationProvider>{children}</TranslationProvider>
</MemoryRouter>
);
const view = renderHook(() => useAdmin(), { wrapper: url === '/admin' ? wrapper : at });
await waitFor(() => expect(view.result.current.isLoading).toBe(false));
return view;
}
const me = buildAdmin({ id: 1, username: 'admin', email: 'admin@example.com' });
const alice = buildUser({ id: 2, username: 'alice', email: 'alice@example.com' });
beforeEach(() => {
resetAllStores();
toastCalls.length = 0;
seedStore(useAuthStore, { isAuthenticated: true, user: me });
// The default handler hands out generated ids; the self-delete guard needs
// the admin row to match the signed-in user.
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [me, alice] })));
});
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
describe('useAdmin', () => {
it('FE-ADMHOOK-001: loads users, stats and invites on mount', async () => {
const { result } = await mountAdmin();
expect(result.current.users.map(u => u.username)).toEqual(['admin', 'alice']);
expect(result.current.stats).toMatchObject({ totalUsers: 2, totalTrips: 5, totalPlaces: 42, totalFiles: 8 });
expect(result.current.invites).toEqual([]);
expect(result.current.activeTab).toBe('users');
});
it('FE-ADMHOOK-002: toasts when the initial load fails', async () => {
server.use(http.get('/api/admin/users', () => HttpResponse.json({}, { status: 500 })));
await mountAdmin();
expect(toastCalls).toContainEqual({ type: 'error', message: 'Failed to load admin data' });
});
it('FE-ADMHOOK-003: survives failing invite endpoints with empty lists', async () => {
server.use(
http.get('/api/admin/invites', () => HttpResponse.json({}, { status: 500 })),
http.get('/api/admin/invites/trips', () => HttpResponse.json({}, { status: 500 }))
);
const { result } = await mountAdmin();
expect(result.current.invites).toEqual([]);
expect(result.current.inviteTrips).toEqual([]);
expect(toastCalls).toEqual([]);
});
it('FE-ADMHOOK-004: reads the auth toggles out of the app config', async () => {
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({
password_login: false,
password_registration: false,
oidc_login: false,
oidc_registration: false,
env_override_oidc_only: true,
oidc_configured: true,
require_mfa: 1,
passkey_login: true,
passkey_configured: true,
allowed_file_types: 'jpg,png',
})
)
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.passwordLogin).toBe(false));
expect(result.current.passwordRegistration).toBe(false);
expect(result.current.oidcLogin).toBe(false);
expect(result.current.oidcRegistration).toBe(false);
expect(result.current.envOverrideOidcOnly).toBe(true);
expect(result.current.oidcConfigured).toBe(true);
expect(result.current.requireMfa).toBe(true);
expect(result.current.passkeyLogin).toBe(true);
expect(result.current.passkeyConfigured).toBe(true);
expect(result.current.allowedFileTypes).toBe('jpg,png');
});
it('FE-ADMHOOK-005: falls back to allow_registration for both registration flags', async () => {
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ allow_registration: false })));
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.passwordRegistration).toBe(false));
expect(result.current.oidcRegistration).toBe(false);
expect(result.current.passwordLogin).toBe(true);
});
it('FE-ADMHOOK-006: a failing app-config keeps the defaults', async () => {
server.use(http.get('/api/auth/app-config', () => HttpResponse.json({}, { status: 500 })));
const { result } = await mountAdmin();
expect(result.current.passwordLogin).toBe(true);
expect(result.current.allowedFileTypes).toContain('jpg');
});
it('FE-ADMHOOK-007: loads the stored API keys', async () => {
server.use(
http.get('/api/auth/me/settings', () =>
HttpResponse.json({
settings: { maps_api_key: 'maps-k', openweather_api_key: 'weather-k', unsplash_api_key: 'unsplash-k' },
})
)
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.mapsKey).toBe('maps-k'));
expect(result.current.weatherKey).toBe('weather-k');
expect(result.current.unsplashKey).toBe('unsplash-k');
});
it('FE-ADMHOOK-008: a failing settings request leaves the keys empty', async () => {
server.use(http.get('/api/auth/me/settings', () => HttpResponse.json({}, { status: 500 })));
const { result } = await mountAdmin();
expect(result.current.mapsKey).toBe('');
});
it('FE-ADMHOOK-009: ignores a version check that reports no update', async () => {
const { result } = await mountAdmin();
expect(result.current.updateInfo).toBeNull();
});
it('FE-ADMHOOK-045: keeps an available update', async () => {
server.use(
http.get('/api/admin/version-check', () =>
HttpResponse.json({ update_available: true, latest: '9.9.9', current: '1.0.0' })
)
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.updateInfo).toMatchObject({ latest: '9.9.9' }));
});
it('FE-ADMHOOK-010: loads the feature flags and app settings', async () => {
server.use(
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
http.get('/api/admin/places-photos', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-autocomplete', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-details', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/collab-features', () =>
HttpResponse.json({ chat: false, notes: true, polls: false, whatsnext: true })
),
http.get('/api/auth/app-settings', () =>
HttpResponse.json({ smtp_host: 'mail.test', webauthn_rp_id: 'trek.test', webauthn_origins: 'https://trek.test' })
)
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.bagTrackingEnabled).toBe(true));
expect(result.current.placesPhotosEnabled).toBe(false);
expect(result.current.placesAutocompleteEnabled).toBe(false);
expect(result.current.placesDetailsEnabled).toBe(false);
expect(result.current.collabFeatures).toEqual({ chat: false, notes: true, polls: false, whatsnext: true });
expect(result.current.smtpValues.smtp_host).toBe('mail.test');
expect(result.current.webauthnRpId).toBe('trek.test');
expect(result.current.webauthnOrigins).toBe('https://trek.test');
expect(result.current.smtpLoaded).toBe(true);
});
it('FE-ADMHOOK-011: marks smtp as loaded even when app-settings fails', async () => {
server.use(http.get('/api/auth/app-settings', () => HttpResponse.json({}, { status: 500 })));
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.smtpLoaded).toBe(true));
expect(result.current.smtpValues).toEqual({});
});
it('FE-ADMHOOK-012: handleToggleAuthSetting applies the value optimistically', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
})
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleToggleAuthSetting('password_login', false, result.current.setPasswordLogin);
});
expect(body).toEqual({ password_login: false });
expect(result.current.passwordLogin).toBe(false);
});
it('FE-ADMHOOK-013: handleToggleAuthSetting rolls back and toasts on failure', async () => {
server.use(
http.put('/api/auth/app-settings', () => HttpResponse.json({ error: 'locked' }, { status: 400 }))
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleToggleAuthSetting('password_login', false, result.current.setPasswordLogin);
});
expect(result.current.passwordLogin).toBe(true);
expect(toastCalls).toContainEqual({ type: 'error', message: 'locked' });
});
it('FE-ADMHOOK-014: handleToggleRequireMfa mirrors the value into the auth store', async () => {
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleToggleRequireMfa(true);
});
expect(result.current.requireMfa).toBe(true);
expect(useAuthStore.getState().appRequireMfa).toBe(true);
expect(toastCalls).toContainEqual({ type: 'success', message: 'Saved' });
});
it('FE-ADMHOOK-015: handleToggleRequireMfa reverts on failure', async () => {
server.use(http.put('/api/auth/app-settings', () => HttpResponse.json({}, { status: 500 })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleToggleRequireMfa(true);
});
expect(result.current.requireMfa).toBe(false);
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
});
it('FE-ADMHOOK-050: handleTogglePlacesGoogleOnly flips the switch and PUTs it', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/places-google-only', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ enabled: true });
})
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleTogglePlacesGoogleOnly();
});
expect(body).toEqual({ enabled: true });
expect(result.current.placesGoogleOnly).toBe(true);
});
it('FE-ADMHOOK-051: a rejected Google-only update rolls the switch back and toasts', async () => {
server.use(
http.put('/api/admin/places-google-only', () => HttpResponse.json({ error: 'locked' }, { status: 500 }))
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleTogglePlacesGoogleOnly();
});
expect(result.current.placesGoogleOnly).toBe(false);
expect(toastCalls).toContainEqual({ type: 'error', message: 'locked' });
});
it('FE-ADMHOOK-016: handleSaveWebauthn trims and re-reads the app config', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/app-settings', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({});
}),
http.get('/api/auth/app-config', () => HttpResponse.json({ passkey_configured: true }))
);
const { result } = await mountAdmin();
act(() => {
result.current.setWebauthnRpId(' trek.test ');
result.current.setWebauthnOrigins(' https://trek.test ');
});
await act(async () => {
await result.current.handleSaveWebauthn();
});
expect(body).toEqual({ webauthn_rp_id: 'trek.test', webauthn_origins: 'https://trek.test' });
expect(result.current.passkeyConfigured).toBe(true);
expect(result.current.savingWebauthn).toBe(false);
});
it('FE-ADMHOOK-017: handleSaveWebauthn toasts and resets the flag on failure', async () => {
server.use(http.put('/api/auth/app-settings', () => HttpResponse.json({}, { status: 500 })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleSaveWebauthn();
});
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
expect(result.current.savingWebauthn).toBe(false);
});
it('FE-ADMHOOK-018: toggleKey flips visibility per key', async () => {
const { result } = await mountAdmin();
act(() => result.current.toggleKey('maps'));
expect(result.current.showKeys).toEqual({ maps: true });
act(() => result.current.toggleKey('maps'));
expect(result.current.showKeys).toEqual({ maps: false });
});
it('FE-ADMHOOK-019: handleSaveApiKeys sends all three keys and toasts', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/auth/me/api-keys', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
})
);
const { result } = await mountAdmin();
act(() => {
result.current.setMapsKey('m');
result.current.setWeatherKey('w');
result.current.setUnsplashKey('u');
});
await act(async () => {
await result.current.handleSaveApiKeys();
});
expect(body).toMatchObject({ maps_api_key: 'm', openweather_api_key: 'w', unsplash_api_key: 'u' });
expect(toastCalls).toContainEqual({ type: 'success', message: 'API keys saved' });
expect(result.current.savingKeys).toBe(false);
});
it('FE-ADMHOOK-052: a key set by the environment locks its field and stays testable (#1881)', async () => {
server.use(
http.get('/api/auth/me/settings', () =>
HttpResponse.json({
settings: { maps_api_key: null, unsplash_api_key: 'unsplash-k', env_keys: { maps_api_key: 'PLACES_API_KEY' } },
})
)
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.keyInputProps('maps').disabled).toBe(true));
expect(result.current.keyInputProps('maps').placeholder).toBe('Set via PLACES_API_KEY');
expect(result.current.keyInputProps('unsplash')).toEqual({ disabled: false, placeholder: 'Enter key...' });
// Test probes what a search resolves to, so the empty field does not block it.
expect(result.current.mapsKey).toBe('');
expect(result.current.mapsKeyTestable).toBe(true);
});
it('FE-ADMHOOK-053: saving leaves a key the environment sets out of the body', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/auth/me/settings', () =>
HttpResponse.json({ settings: { maps_api_key: null, env_keys: { maps_api_key: 'PLACES_API_KEY' } } })
),
http.put('/api/auth/me/api-keys', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
})
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.mapsKeyTestable).toBe(true));
act(() => result.current.setUnsplashKey('u'));
await act(async () => {
await result.current.handleSaveApiKeys();
});
// Sending the empty field would clear the stored value the install falls
// back to once the variable is removed.
expect(body).not.toHaveProperty('maps_api_key');
expect(body).toMatchObject({ unsplash_api_key: 'u' });
});
it('FE-ADMHOOK-020: handleSaveApiKeys surfaces the thrown error message', async () => {
server.use(http.put('/api/auth/me/api-keys', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleSaveApiKeys();
});
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
expect(result.current.savingKeys).toBe(false);
});
it('FE-ADMHOOK-021: handleValidateKeys stores the full validation result', async () => {
server.use(
http.put('/api/auth/me/api-keys', () => HttpResponse.json({ success: true })),
http.get('/api/auth/validate-keys', () => HttpResponse.json({ maps: true, weather: false }))
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleValidateKeys();
});
expect(result.current.validation).toEqual({ maps: true, weather: false });
expect(result.current.validating).toEqual({});
});
it('FE-ADMHOOK-022: handleValidateKeys toasts and clears the flags on failure', async () => {
server.use(http.get('/api/auth/validate-keys', () => HttpResponse.json({}, { status: 500 })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleValidateKeys();
});
expect(toastCalls).toContainEqual({ type: 'error', message: 'Error' });
expect(result.current.validating).toEqual({});
});
it('FE-ADMHOOK-023: handleValidateKey stores only the requested key', async () => {
server.use(
http.put('/api/auth/me/api-keys', () => HttpResponse.json({ success: true })),
http.get('/api/auth/validate-keys', () => HttpResponse.json({ maps: false, weather: true }))
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleValidateKey('maps');
});
expect(result.current.validation).toEqual({ maps: false });
expect(result.current.validating).toEqual({ maps: false });
});
it('FE-ADMHOOK-024: handleValidateKey toasts on failure', async () => {
server.use(http.get('/api/auth/validate-keys', () => HttpResponse.json({}, { status: 500 })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleValidateKey('maps');
});
expect(toastCalls).toContainEqual({ type: 'error', message: 'Error' });
expect(result.current.validating).toEqual({ maps: false });
});
it('FE-ADMHOOK-025: handleCreateUser rejects an incomplete form', async () => {
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleCreateUser();
});
expect(toastCalls).toContainEqual({
type: 'error',
message: 'Username, email and password are required',
});
expect(result.current.users).toHaveLength(2);
});
it('FE-ADMHOOK-026: handleCreateUser rejects a short password', async () => {
const { result } = await mountAdmin();
act(() => result.current.setCreateForm({ username: 'bob', email: 'b@e.com', password: 'short', role: 'user' }));
await act(async () => {
await result.current.handleCreateUser();
});
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
expect(result.current.users).toHaveLength(2);
});
it('FE-ADMHOOK-027: handleCreateUser prepends the new user and resets the form', async () => {
const { result } = await mountAdmin();
act(() =>
result.current.setCreateForm({ username: 'bob', email: 'b@e.com', password: 'Longenough1!', role: 'admin' })
);
act(() => result.current.setShowCreateUser(true));
await act(async () => {
await result.current.handleCreateUser();
});
expect(result.current.users[0].username).toBe('bob');
expect(result.current.showCreateUser).toBe(false);
expect(result.current.createForm).toEqual({ username: '', email: '', password: '', role: 'user' });
expect(toastCalls).toContainEqual({ type: 'success', message: 'User created' });
});
it('FE-ADMHOOK-028: handleCreateUser surfaces the server error', async () => {
server.use(http.post('/api/admin/users', () => HttpResponse.json({ error: 'taken' }, { status: 409 })));
const { result } = await mountAdmin();
act(() =>
result.current.setCreateForm({ username: 'bob', email: 'b@e.com', password: 'Longenough1!', role: 'user' })
);
await act(async () => {
await result.current.handleCreateUser();
});
expect(toastCalls).toContainEqual({ type: 'error', message: 'taken' });
expect(result.current.users).toHaveLength(2);
});
it('FE-ADMHOOK-029: handleCreateInvite prepends the invite and copies the link', async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, writable: true, configurable: true });
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/admin/invites', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ invite: { id: 7, token: 'tok-7' } });
})
);
const { result } = await mountAdmin();
act(() => result.current.setInviteForm({ max_uses: 3, expires_in_days: '', trip_id: '' }));
await act(async () => {
await result.current.handleCreateInvite();
});
expect(body).toEqual({ max_uses: 3, trip_id: null });
expect(result.current.invites[0]).toMatchObject({ token: 'tok-7' });
expect(result.current.inviteForm).toEqual({ max_uses: 1, expires_in_days: 7, trip_id: '' });
await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/register?invite=tok-7')));
});
it('FE-ADMHOOK-030: handleCreateInvite forwards an explicit trip binding', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn(async () => undefined) },
writable: true,
configurable: true,
});
let body: Record<string, unknown> | null = null;
server.use(
http.post('/api/admin/invites', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ invite: { id: 8, token: 'tok-8' } });
})
);
const { result } = await mountAdmin();
act(() => result.current.setInviteForm({ max_uses: 1, expires_in_days: 14, trip_id: 42 }));
await act(async () => {
await result.current.handleCreateInvite();
});
expect(body).toEqual({ max_uses: 1, expires_in_days: 14, trip_id: 42 });
});
it('FE-ADMHOOK-031: handleCreateInvite toasts the server error', async () => {
server.use(http.post('/api/admin/invites', () => HttpResponse.json({ error: 'no invites' }, { status: 400 })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleCreateInvite();
});
expect(toastCalls).toContainEqual({ type: 'error', message: 'no invites' });
});
it('FE-ADMHOOK-032: handleDeleteInvite removes it from the list', async () => {
server.use(
http.get('/api/admin/invites', () =>
HttpResponse.json({ invites: [{ id: 5, token: 'a' }, { id: 6, token: 'b' }] })
)
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleDeleteInvite(5);
});
expect(result.current.invites.map(i => i.id)).toEqual([6]);
expect(toastCalls).toContainEqual({ type: 'success', message: 'Invite link deleted' });
});
it('FE-ADMHOOK-033: a failing invite delete keeps the list intact', async () => {
server.use(
http.get('/api/admin/invites', () => HttpResponse.json({ invites: [{ id: 5, token: 'a' }] })),
http.delete('/api/admin/invites/:id', () => HttpResponse.json({}, { status: 500 }))
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleDeleteInvite(5);
});
expect(result.current.invites).toHaveLength(1);
expect(toastCalls).toContainEqual({ type: 'error', message: 'Failed to delete invite link' });
});
it('FE-ADMHOOK-034: copyInviteLink writes a full registration URL', async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, writable: true, configurable: true });
const { result } = await mountAdmin();
act(() => result.current.copyInviteLink('tok-9'));
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/register?invite=tok-9`);
await waitFor(() => expect(toastCalls).toContainEqual({ type: 'success', message: 'Invite link copied to clipboard' }));
});
it('FE-ADMHOOK-035: handleEditUser seeds the edit form from the user', async () => {
const { result } = await mountAdmin();
const row = result.current.users[1];
act(() => result.current.handleEditUser(row));
expect(result.current.editingUser).toBe(row);
expect(result.current.editForm).toEqual({
username: 'alice',
email: 'alice@example.com',
role: 'user',
password: '',
});
});
it('FE-ADMHOOK-036b: handleSaveUser does nothing without a user in the editor', async () => {
let called = false;
server.use(
http.put('/api/admin/users/:id', () => {
called = true;
return HttpResponse.json({ user: buildUser({}) });
})
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleSaveUser();
});
expect(called).toBe(false);
expect(toastCalls).toEqual([]);
});
it('FE-ADMHOOK-036: handleSaveUser sends the trimmed fields and replaces the row', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/users/:id', async ({ params, request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ user: buildUser({ id: Number(params.id), username: 'alice2' }) });
})
);
const { result } = await mountAdmin();
act(() => result.current.handleEditUser(result.current.users[1]));
act(() => result.current.setEditForm({ username: ' alice2 ', email: ' a2@e.com ', role: 'admin', password: '' }));
await act(async () => {
await result.current.handleSaveUser();
});
expect(body).toEqual({ username: 'alice2', email: 'a2@e.com', role: 'admin' });
expect(result.current.users[1].username).toBe('alice2');
expect(result.current.editingUser).toBeNull();
expect(toastCalls).toContainEqual({ type: 'success', message: 'User updated' });
});
it('FE-ADMHOOK-036d: the open users tab asks again who is online, without the spinner', async () => {
const ticks: Array<() => void> = [];
const realSetInterval = window.setInterval.bind(window);
// Only the hook's 30 s refresh is caught; waitFor keeps its own polling.
const spy = vi.spyOn(window, 'setInterval').mockImplementation(((fn: () => void, ms?: number) => {
if (ms !== 30_000) return realSetInterval(fn, ms);
ticks.push(fn);
return 0;
}) as unknown as typeof window.setInterval);
try {
const { result } = await mountAdmin();
expect(result.current.users[1].online).not.toBe(true);
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [me, { ...alice, online: true }] })));
await act(async () => {
ticks.forEach((tick) => tick());
});
await waitFor(() => expect(result.current.users[1].online).toBe(true));
expect(result.current.isLoading).toBe(false);
} finally {
spy.mockRestore();
}
});
it('FE-ADMHOOK-036c: a saved row keeps what the list derives (avatar, activity, online)', async () => {
server.use(
http.put('/api/admin/users/:id', ({ params }) =>
// The update answers with the account fields only.
HttpResponse.json({ user: { id: Number(params.id), username: 'alice2', email: 'a2@e.com', role: 'user', created_at: '2026-01-01 00:00:00' } })
)
);
const { result } = await mountAdmin();
const before = result.current.users[1];
act(() => result.current.handleEditUser(before));
act(() => result.current.setEditForm({ username: 'alice2', email: 'a2@e.com', role: 'user', password: '' }));
await act(async () => {
await result.current.handleSaveUser();
});
const after = result.current.users[1];
expect(after).toMatchObject({ username: 'alice2', email: 'a2@e.com' });
expect(after.last_login).toBe(before.last_login);
expect(after.online).toBe(before.online);
expect(after.avatar_url).toBe(before.avatar_url);
expect(after.created_at).toBe(before.created_at);
});
it('FE-ADMHOOK-037: handleSaveUser refuses a password shorter than eight characters', async () => {
let called = false;
server.use(
http.put('/api/admin/users/:id', () => {
called = true;
return HttpResponse.json({ user: buildUser({}) });
})
);
const { result } = await mountAdmin();
act(() => result.current.handleEditUser(result.current.users[1]));
act(() => result.current.setEditForm({ username: 'alice', email: 'a@e.com', role: 'user', password: 'short' }));
await act(async () => {
await result.current.handleSaveUser();
});
expect(called).toBe(false);
expect(result.current.editingUser).not.toBeNull();
expect(toastCalls.some(c => c.type === 'error')).toBe(true);
});
it('FE-ADMHOOK-038: handleSaveUser forwards a long enough password', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.put('/api/admin/users/:id', async ({ params, request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ user: buildUser({ id: Number(params.id) }) });
})
);
const { result } = await mountAdmin();
act(() => result.current.handleEditUser(result.current.users[1]));
act(() =>
result.current.setEditForm({ username: 'alice', email: 'a@e.com', role: 'user', password: ' Longenough1! ' })
);
await act(async () => {
await result.current.handleSaveUser();
});
expect(body).toMatchObject({ password: 'Longenough1!' });
});
it('FE-ADMHOOK-039: handleSaveUser surfaces the server error', async () => {
server.use(http.put('/api/admin/users/:id', () => HttpResponse.json({ error: 'conflict' }, { status: 409 })));
const { result } = await mountAdmin();
act(() => result.current.handleEditUser(result.current.users[1]));
await act(async () => {
await result.current.handleSaveUser();
});
expect(toastCalls).toContainEqual({ type: 'error', message: 'conflict' });
expect(result.current.editingUser).not.toBeNull();
});
it('FE-ADMHOOK-040: handleDeleteUser refuses to delete the signed-in admin', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleDeleteUser(result.current.users[0]);
});
expect(confirmSpy).not.toHaveBeenCalled();
expect(toastCalls).toContainEqual({ type: 'error', message: 'Cannot delete your own account' });
expect(result.current.users).toHaveLength(2);
});
it('FE-ADMHOOK-041: handleDeleteUser aborts when the confirm is declined', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleDeleteUser(result.current.users[1]);
});
expect(result.current.users).toHaveLength(2);
expect(toastCalls).toEqual([]);
});
it('FE-ADMHOOK-042: a confirmed delete removes the user from the list', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
let deletedId: string | undefined;
server.use(
http.delete('/api/admin/users/:id', ({ params }) => {
deletedId = params.id as string;
return HttpResponse.json({ success: true });
})
);
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleDeleteUser(result.current.users[1]);
});
expect(deletedId).toBe('2');
expect(result.current.users.map(u => u.username)).toEqual(['admin']);
expect(toastCalls).toContainEqual({ type: 'success', message: 'User deleted' });
});
it('FE-ADMHOOK-042b: a delete the caller already confirmed skips the browser confirm', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
server.use(http.delete('/api/admin/users/:id', () => HttpResponse.json({ success: true })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleDeleteUser(result.current.users[1], { confirmed: true });
});
expect(confirmSpy).not.toHaveBeenCalled();
expect(result.current.users.map(u => u.username)).toEqual(['admin']);
});
it('FE-ADMHOOK-043: a failing delete surfaces the server error', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
server.use(http.delete('/api/admin/users/:id', () => HttpResponse.json({ error: 'busy' }, { status: 500 })));
const { result } = await mountAdmin();
await act(async () => {
await result.current.handleDeleteUser(result.current.users[1]);
});
expect(toastCalls).toContainEqual({ type: 'error', message: 'busy' });
expect(result.current.users).toHaveLength(2);
});
it('FE-ADMHOOK-046: opens on the tab the URL asks for', async () => {
const { result } = await mountAdmin('/admin?tab=audit');
expect(result.current.activeTab).toBe('audit');
});
/*
* A tab id with no panel behind it — a typo, or a link from an install that
* has tabs this one does not — used to open the page on an empty content
* area with nothing selected in the sidebar.
*/
it('FE-ADMHOOK-047: falls back to users when the URL names a tab that does not exist', async () => {
const { result } = await mountAdmin('/admin?tab=nonsense');
expect(result.current.activeTab).toBe('users');
});
/*
* The sidebar entries are built conditionally but the panels are not, so a
* hosted install had its hidden panels one URL away.
*/
it('FE-ADMHOOK-048: refuses a tab a managed install hides', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: me, managed: true });
const { result } = await mountAdmin('/admin?tab=backup');
await waitFor(() => expect(result.current.activeTab).toBe('users'));
});
it('FE-ADMHOOK-049: keeps that same tab on an install that runs itself', async () => {
const { result } = await mountAdmin('/admin?tab=backup');
expect(result.current.activeTab).toBe('backup');
});
it('FE-ADMHOOK-044: mcpEnabled follows the addon store', async () => {
const { result } = await mountAdmin();
expect(result.current.mcpEnabled).toBe(false);
expect(result.current.hour12).toBe(false);
expect(result.current.currentUser?.username).toBe('admin');
});
it('FE-ADMHOOK-054: saving the demo baseline toasts the outcome, with the server message on failure', async () => {
const save = vi.spyOn(adminApi, 'saveDemoBaseline').mockResolvedValue({});
const { result } = await mountAdmin();
await act(() => result.current.saveDemoBaseline());
expect(save).toHaveBeenCalledTimes(1);
expect(toastCalls).toContainEqual({ type: 'success', message: 'Baseline saved! Resets will restore to this state.' });
save.mockRejectedValueOnce({ response: { data: { error: 'Not a demo' } } });
await act(() => result.current.saveDemoBaseline());
expect(toastCalls).toContainEqual({ type: 'error', message: 'Not a demo' });
save.mockRejectedValueOnce(new Error('offline'));
await act(() => result.current.saveDemoBaseline());
expect(toastCalls).toContainEqual({ type: 'error', message: 'Failed to save baseline' });
});
it('FE-ADMHOOK-055: bag tracking flips at once and saves the new value', async () => {
const update = vi.spyOn(adminApi, 'updateBagTracking').mockResolvedValue({});
const { result } = await mountAdmin();
const before = result.current.bagTrackingEnabled;
await act(() => result.current.toggleBagTracking());
expect(update).toHaveBeenCalledWith(!before);
expect(result.current.bagTrackingEnabled).toBe(!before);
});
it('FE-ADMHOOK-056: a failed bag tracking save flips back without a toast', async () => {
vi.spyOn(adminApi, 'updateBagTracking').mockRejectedValue(new Error('x'));
const { result } = await mountAdmin();
const before = result.current.bagTrackingEnabled;
const toastsBefore = toastCalls.length;
await act(() => result.current.toggleBagTracking());
expect(result.current.bagTrackingEnabled).toBe(before);
expect(toastCalls).toHaveLength(toastsBefore);
});
it('FE-ADMHOOK-057: a collab feature toggle saves only that key', async () => {
vi.spyOn(adminApi, 'getCollabFeatures').mockResolvedValue({ chat: true, notes: true, polls: true, whatsnext: true });
const update = vi.spyOn(adminApi, 'updateCollabFeatures').mockResolvedValue({});
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.collabFeatures.chat).toBe(true));
await act(() => result.current.toggleCollabFeature('chat'));
expect(update).toHaveBeenCalledWith({ chat: false });
expect(result.current.collabFeatures).toEqual({ chat: false, notes: true, polls: true, whatsnext: true });
});
it('FE-ADMHOOK-058: a failed collab toggle rolls back its own key and keeps a later one', async () => {
vi.spyOn(adminApi, 'getCollabFeatures').mockResolvedValue({ chat: true, notes: true, polls: true, whatsnext: true });
let fail!: (e: unknown) => void;
vi.spyOn(adminApi, 'updateCollabFeatures')
.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)))
.mockResolvedValueOnce({});
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.collabFeatures.chat).toBe(true));
let pending!: Promise<void>;
act(() => {
pending = result.current.toggleCollabFeature('chat');
});
await act(() => result.current.toggleCollabFeature('notes'));
await act(async () => {
fail(new Error('x'));
await pending;
});
expect(result.current.collabFeatures).toEqual({ chat: true, notes: false, polls: true, whatsnext: true });
});
});