207 lines
6.0 KiB
TypeScript
207 lines
6.0 KiB
TypeScript
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { apiMock, useSessionMock } = vi.hoisted(() => ({
|
|
apiMock: vi.fn(),
|
|
useSessionMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/api', () => ({
|
|
api: apiMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/auth-client', () => ({
|
|
useSession: useSessionMock,
|
|
authClient: {},
|
|
}));
|
|
|
|
import { AdminPage } from './admin';
|
|
import { AdminGuard } from '@/spa/guards';
|
|
|
|
const userFixtures = {
|
|
users: [
|
|
{
|
|
id: 'u-admin',
|
|
name: 'Ada Admin',
|
|
email: '[email protected]',
|
|
role: 'admin',
|
|
banned: false,
|
|
banReason: null,
|
|
createdAt: '2026-08-01T00:00:00.000Z',
|
|
updatedAt: '2026-08-01T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'u-member',
|
|
name: 'Mel Member',
|
|
email: '[email protected]',
|
|
role: 'member',
|
|
banned: true,
|
|
banReason: 'spam',
|
|
createdAt: '2026-08-02T00:00:00.000Z',
|
|
updatedAt: '2026-08-02T00:00:00.000Z',
|
|
},
|
|
],
|
|
total: 2,
|
|
};
|
|
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement;
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
configurable: true,
|
|
value: true,
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
root?.unmount();
|
|
});
|
|
document.body.replaceChildren();
|
|
root = null;
|
|
apiMock.mockReset();
|
|
useSessionMock.mockReset();
|
|
});
|
|
|
|
async function renderAdminRoute(): Promise<void> {
|
|
const routes: RouteObject[] = [
|
|
{
|
|
element: <AdminGuard />,
|
|
children: [{ path: '/admin', element: <AdminPage /> }],
|
|
},
|
|
{ path: '/', element: <div>home page</div> },
|
|
{ path: '/login', element: <div>login page</div> },
|
|
];
|
|
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
|
|
container = document.createElement('div');
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
|
|
await act(async () => {
|
|
root?.render(<RouterProvider router={router} />);
|
|
});
|
|
}
|
|
|
|
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
|
|
return {
|
|
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
|
|
isPending: false,
|
|
};
|
|
}
|
|
|
|
describe('AdminGuard', () => {
|
|
it('redirects unauthenticated visitors to /login', async () => {
|
|
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
|
|
await renderAdminRoute();
|
|
|
|
expect(container.textContent).toContain('login page');
|
|
expect(apiMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('redirects non-admin users to /', async () => {
|
|
useSessionMock.mockReturnValue(sessionWithRole('member'));
|
|
|
|
await renderAdminRoute();
|
|
|
|
expect(container.textContent).toContain('home page');
|
|
expect(apiMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders the admin page for admin users', async () => {
|
|
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
apiMock.mockResolvedValueOnce(userFixtures);
|
|
|
|
await renderAdminRoute();
|
|
|
|
expect(container.textContent).toContain('Admin Panel');
|
|
});
|
|
});
|
|
|
|
describe('AdminPage users tab', () => {
|
|
it('lists users with role and ban status after load', async () => {
|
|
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
apiMock.mockResolvedValueOnce(userFixtures);
|
|
|
|
await renderAdminRoute();
|
|
|
|
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
|
|
expect(container.textContent).toContain('Ada Admin');
|
|
expect(container.textContent).toContain('Mel Member');
|
|
expect(container.textContent).toContain('Banned');
|
|
expect(container.textContent).toContain('2 user(s)');
|
|
});
|
|
|
|
it('shows the load error with a retry control', async () => {
|
|
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
|
|
|
|
await renderAdminRoute();
|
|
|
|
expect(container.textContent).toContain('gateway unavailable');
|
|
|
|
apiMock.mockResolvedValueOnce(userFixtures);
|
|
const retry = [...container.querySelectorAll('button')].find((b) =>
|
|
b.textContent?.includes('Retry'),
|
|
);
|
|
expect(retry).toBeTruthy();
|
|
await act(async () => {
|
|
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
|
|
expect(container.textContent).toContain('Ada Admin');
|
|
});
|
|
|
|
it('posts to the ban endpoint and reloads on Ban', async () => {
|
|
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
apiMock.mockResolvedValue(userFixtures);
|
|
|
|
await renderAdminRoute();
|
|
|
|
const banButton = [...container.querySelectorAll('button')].find(
|
|
(b) => b.textContent === 'Ban',
|
|
);
|
|
expect(banButton).toBeTruthy();
|
|
await act(async () => {
|
|
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
|
|
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
|
|
});
|
|
});
|
|
|
|
describe('AdminPage health tab', () => {
|
|
it('loads health status when the tab is opened', async () => {
|
|
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
|
|
status: 'ok',
|
|
database: { status: 'ok', latencyMs: 3 },
|
|
cache: { status: 'ok', latencyMs: 1 },
|
|
agentPool: { activeSessions: 2 },
|
|
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
|
|
checkedAt: '2026-08-26T00:00:00.000Z',
|
|
});
|
|
|
|
await renderAdminRoute();
|
|
|
|
const healthTab = [...container.querySelectorAll('button')].find((b) =>
|
|
b.textContent?.includes('System Health'),
|
|
);
|
|
await act(async () => {
|
|
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
});
|
|
|
|
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
|
|
expect(container.textContent).toContain('Database (PostgreSQL)');
|
|
expect(container.textContent).toContain('Active sessions: 2');
|
|
expect(container.textContent).toContain('4 models');
|
|
});
|
|
});
|