feat(web): add /admin layout with role-gated load + sidebar

Hard route gate in admin/+layout.ts redirects non-admins to / before
the layout (or any child) renders. AdminSidebar reads the active route
from $app/state and applies a 12% accent-tinted bg + 2px forest-teal
left strip on the active item. Overview landing shows pending-request
count and Lidarr connected/unset, each linking to its sub-page. Shell
nav exposes the Admin link only to is_admin users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 22:48:26 -04:00
parent dd86ffca94
commit bfd48f5a02
8 changed files with 333 additions and 2 deletions
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
// Mutable mock-user handle so individual tests can flip is_admin / null
// without re-importing the module under test.
const userState = vi.hoisted(() => ({
current: null as { id: string; username: string; is_admin: boolean } | null
}));
vi.mock('$lib/auth/store.svelte', () => ({
user: { get value() { return userState.current; } }
}));
vi.mock('$app/state', () => ({
page: { url: new URL('http://localhost/admin') }
}));
vi.mock('$lib/api/admin', () => ({
createAdminRequestsQuery: vi.fn(),
createLidarrConfigQuery: vi.fn()
}));
afterEach(() => {
vi.clearAllMocks();
userState.current = null;
});
describe('admin/+layout.ts load gate', () => {
test('non-admin user throws redirect to /', async () => {
userState.current = { id: 'u1', username: 'alice', is_admin: false };
const { load } = await import('./+layout');
await expect(async () =>
// The SvelteKit `load` signature wants a LoadEvent; we don't read any of
// it inside the function, so an empty cast is sufficient for this gate.
load({} as Parameters<typeof load>[0])
).rejects.toMatchObject({ status: 302, location: '/' });
});
test('unauthenticated (null user) throws redirect to /', async () => {
userState.current = null;
const { load } = await import('./+layout');
await expect(async () =>
load({} as Parameters<typeof load>[0])
).rejects.toMatchObject({ status: 302, location: '/' });
});
test('admin user passes the gate', async () => {
userState.current = { id: 'u1', username: 'root', is_admin: true };
const { load } = await import('./+layout');
// load() is synchronous in the happy path; it only throws on the gate.
expect(load({} as Parameters<typeof load>[0])).toEqual({});
});
});
describe('admin Overview page', () => {
test('renders pending requests count and Lidarr status (connected)', async () => {
const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ id: '1' }, { id: '2' }] })
);
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: { enabled: true } })
);
const { default: OverviewPage } = await import('./+page.svelte');
render(OverviewPage);
expect(screen.getByText('Pending requests')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('Lidarr')).toBeInTheDocument();
expect(screen.getByText(/connected/i)).toBeInTheDocument();
});
test('shows zero pending and "Unset" when nothing is configured', async () => {
const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [] })
);
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: { enabled: false } })
);
const { default: OverviewPage } = await import('./+page.svelte');
render(OverviewPage);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText(/unset/i)).toBeInTheDocument();
});
test('Overview cards link to their sub-pages', async () => {
const { createAdminRequestsQuery, createLidarrConfigQuery } = await import('$lib/api/admin');
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [] })
);
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: { enabled: false } })
);
const { default: OverviewPage } = await import('./+page.svelte');
render(OverviewPage);
const reqLink = screen.getByRole('link', { name: /pending requests/i });
expect(reqLink).toHaveAttribute('href', '/admin/requests');
const lidarrLink = screen.getByRole('link', { name: /lidarr/i });
expect(lidarrLink).toHaveAttribute('href', '/admin/integrations');
});
});