mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add admin system logs UI and API support
Introduce a complete admin System Logs feature: adds frontend components (filters, date picker, active filter chips, rows, detail panel, skeletons, pagination, toolbar, user typeahead, and styles) under src/app/admin/logs/components, plus hooks (useAutoRefreshControl, useLogsUrlState, useUserSearch) and types. Add constants for job labels and log filters, wire URL-driven filters/search/date-range/hasError/user/audiobookQuery with pause-on-interact behavior and page-size options. Update API route (/api/admin/logs) to support the expanded query params and exported where-builder. Update documentation (TABLEOFCONTENTS and admin-dashboard) and add/adjust tests for the new admin logs UI and API behavior.
This commit is contained in:
@@ -21,6 +21,18 @@ vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
async function callRoute(query: string = '') {
|
||||
prismaMock.job.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.job.count.mockResolvedValueOnce(0);
|
||||
const { GET } = await import('@/app/api/admin/logs/route');
|
||||
const url = `http://localhost/api/admin/logs${query ? `?${query}` : ''}`;
|
||||
const response = await GET({ url } as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.job.findMany.mock.calls[0][0];
|
||||
const countArgs = prismaMock.job.count.mock.calls[0][0];
|
||||
return { response, payload, findManyArgs, countArgs };
|
||||
}
|
||||
|
||||
describe('Admin logs route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -34,12 +46,209 @@ describe('Admin logs route', () => {
|
||||
prismaMock.job.count.mockResolvedValueOnce(1);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/logs/route');
|
||||
const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=10' } as any);
|
||||
const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=25' } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.logs).toHaveLength(1);
|
||||
expect(payload.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
describe('where composition', () => {
|
||||
it('builds empty where when no filters provided', async () => {
|
||||
const { findManyArgs } = await callRoute();
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies status filter only when not "all"', async () => {
|
||||
const { findManyArgs } = await callRoute('status=failed');
|
||||
expect(findManyArgs.where).toEqual({ status: 'failed' });
|
||||
});
|
||||
|
||||
it('skips status when value is "all"', async () => {
|
||||
const { findManyArgs } = await callRoute('status=all');
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies type filter only when not "all"', async () => {
|
||||
const { findManyArgs } = await callRoute('type=scan_plex');
|
||||
expect(findManyArgs.where).toEqual({ type: 'scan_plex' });
|
||||
});
|
||||
|
||||
it('applies dateFrom and dateTo as createdAt range', async () => {
|
||||
const { findManyArgs } = await callRoute(
|
||||
'dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z'
|
||||
);
|
||||
expect(findManyArgs.where.createdAt).toEqual({
|
||||
gte: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lte: new Date('2026-02-01T00:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('silently drops invalid date strings', async () => {
|
||||
const { findManyArgs } = await callRoute('dateFrom=not-a-date&dateTo=also-not-a-date');
|
||||
expect(findManyArgs.where.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies hasError=true as OR of failed/stuck status or non-null errorMessage', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=true');
|
||||
expect(findManyArgs.where.OR).toEqual([
|
||||
{ status: { in: ['failed', 'stuck'] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('also accepts hasError=1 as truthy', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=1');
|
||||
expect(findManyArgs.where.OR).toEqual([
|
||||
{ status: { in: ['failed', 'stuck'] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats hasError=false as no errors filter', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=false');
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies userId filter via request.is.userId', async () => {
|
||||
const { findManyArgs } = await callRoute('userId=user-abc-123');
|
||||
expect(findManyArgs.where.request).toEqual({ is: { userId: 'user-abc-123' } });
|
||||
});
|
||||
|
||||
it('applies audiobookQuery as OR-contains on audiobook title/author', async () => {
|
||||
const { findManyArgs } = await callRoute('audiobookQuery=Sanderson');
|
||||
expect(findManyArgs.where.request).toEqual({
|
||||
is: {
|
||||
audiobook: {
|
||||
is: {
|
||||
OR: [
|
||||
{ title: { contains: 'Sanderson', mode: 'insensitive' } },
|
||||
{ author: { contains: 'Sanderson', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('search applies six-column OR with bullJobId startsWith (case-sensitive)', async () => {
|
||||
const { findManyArgs } = await callRoute('search=abc123');
|
||||
const or = findManyArgs.where.OR;
|
||||
expect(Array.isArray(or)).toBe(true);
|
||||
expect(or).toHaveLength(6);
|
||||
expect(or[0]).toEqual({ bullJobId: { startsWith: 'abc123' } });
|
||||
expect(or[1]).toEqual({ errorMessage: { contains: 'abc123', mode: 'insensitive' } });
|
||||
});
|
||||
|
||||
it('search includes events.some.message clause for event-text search', async () => {
|
||||
const { findManyArgs } = await callRoute('search=timeout');
|
||||
const hasEventClause = findManyArgs.where.OR.some(
|
||||
(clause: any) =>
|
||||
clause.events?.some?.message?.contains === 'timeout' &&
|
||||
clause.events?.some?.message?.mode === 'insensitive'
|
||||
);
|
||||
expect(hasEventClause).toBe(true);
|
||||
});
|
||||
|
||||
it('search includes audiobook title/author and plexUsername clauses', async () => {
|
||||
const { findManyArgs } = await callRoute('search=foo');
|
||||
const or = findManyArgs.where.OR;
|
||||
const findRequestClause = (path: (clause: any) => any) =>
|
||||
or.find((clause: any) => path(clause) === 'foo');
|
||||
expect(findRequestClause((c: any) => c.request?.is?.audiobook?.is?.title?.contains)).toBeTruthy();
|
||||
expect(findRequestClause((c: any) => c.request?.is?.audiobook?.is?.author?.contains)).toBeTruthy();
|
||||
expect(findRequestClause((c: any) => c.request?.is?.user?.is?.plexUsername?.contains)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats whitespace-only search as no search', async () => {
|
||||
const { findManyArgs } = await callRoute('search=%20%20%20');
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats whitespace-only audiobookQuery as no filter', async () => {
|
||||
const { findManyArgs } = await callRoute('audiobookQuery=%20');
|
||||
expect(findManyArgs.where.request).toBeUndefined();
|
||||
});
|
||||
|
||||
it('combines hasError and search under top-level AND wrapper', async () => {
|
||||
const { findManyArgs } = await callRoute('hasError=true&search=oom');
|
||||
expect(findManyArgs.where.AND).toBeDefined();
|
||||
expect(findManyArgs.where.AND).toHaveLength(2);
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
const orClauses = findManyArgs.where.AND.map((c: any) => c.OR);
|
||||
expect(orClauses[0]).toEqual([
|
||||
{ status: { in: ['failed', 'stuck'] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]);
|
||||
expect(Array.isArray(orClauses[1])).toBe(true);
|
||||
expect(orClauses[1]).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('combines all filters together', async () => {
|
||||
const { findManyArgs } = await callRoute(
|
||||
'status=failed&type=scan_plex&dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z&userId=user-1&audiobookQuery=Way%20of%20Kings&hasError=true&search=disk'
|
||||
);
|
||||
const where = findManyArgs.where;
|
||||
expect(where.status).toBe('failed');
|
||||
expect(where.type).toBe('scan_plex');
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-01-01T00:00:00.000Z'));
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-02-01T00:00:00.000Z'));
|
||||
expect(where.request.is.userId).toBe('user-1');
|
||||
expect(where.request.is.audiobook.is.OR).toHaveLength(2);
|
||||
expect(where.AND).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses identical where for findMany and count', async () => {
|
||||
const { findManyArgs, countArgs } = await callRoute('status=failed&hasError=true');
|
||||
expect(countArgs.where).toEqual(findManyArgs.where);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limit clamp', () => {
|
||||
const cases: Array<[string | null, number]> = [
|
||||
['25', 25],
|
||||
['50', 50],
|
||||
['100', 100],
|
||||
['24', 50],
|
||||
['75', 50],
|
||||
['101', 50],
|
||||
['abc', 50],
|
||||
[null, 50],
|
||||
];
|
||||
|
||||
for (const [raw, expected] of cases) {
|
||||
it(`limit=${raw} → take ${expected}`, async () => {
|
||||
const query = raw === null ? '' : `limit=${raw}`;
|
||||
const { findManyArgs, payload } = await callRoute(query);
|
||||
expect(findManyArgs.take).toBe(expected);
|
||||
expect(payload.pagination.limit).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('pagination math', () => {
|
||||
it('page=2 with limit=50 and total=75 returns totalPages=2 and skip=50', async () => {
|
||||
prismaMock.job.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.job.count.mockResolvedValueOnce(75);
|
||||
const { GET } = await import('@/app/api/admin/logs/route');
|
||||
const response = await GET({
|
||||
url: 'http://localhost/api/admin/logs?page=2&limit=50',
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.job.findMany.mock.calls[0][0];
|
||||
|
||||
expect(findManyArgs.skip).toBe(50);
|
||||
expect(findManyArgs.take).toBe(50);
|
||||
expect(payload.pagination.page).toBe(2);
|
||||
expect(payload.pagination.limit).toBe(50);
|
||||
expect(payload.pagination.total).toBe(75);
|
||||
expect(payload.pagination.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
it('coerces invalid page to 1', async () => {
|
||||
const { findManyArgs, payload } = await callRoute('page=-3');
|
||||
expect(findManyArgs.skip).toBe(0);
|
||||
expect(payload.pagination.page).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Component: Admin Logs — ActiveFilterChips Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ActiveFilterChips from '@/app/admin/logs/components/ActiveFilterChips';
|
||||
import { DEFAULT_FILTER_STATE, type LogsFilterState } from '@/app/admin/logs/types';
|
||||
|
||||
const setFiltersMock = vi.fn();
|
||||
const removeFilterMock = vi.fn();
|
||||
const clearAllMock = vi.fn();
|
||||
let mockFilters: LogsFilterState = { ...DEFAULT_FILTER_STATE };
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useLogsUrlState', () => ({
|
||||
useLogsUrlState: () => ({
|
||||
filters: mockFilters,
|
||||
setFilters: setFiltersMock,
|
||||
setSearchInput: vi.fn(),
|
||||
searchInput: mockFilters.search,
|
||||
clearAll: clearAllMock,
|
||||
removeFilter: removeFilterMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const findUserByIdMock = vi.fn();
|
||||
vi.mock('@/app/admin/logs/hooks/useUserSearch', () => ({
|
||||
useUserSearch: () => ({
|
||||
users: [],
|
||||
filterByQuery: vi.fn(),
|
||||
findUserById: findUserByIdMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ActiveFilterChips', () => {
|
||||
beforeEach(() => {
|
||||
setFiltersMock.mockReset();
|
||||
removeFilterMock.mockReset();
|
||||
findUserByIdMock.mockReset();
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE };
|
||||
});
|
||||
|
||||
it('renders nothing when all filters are at default and no search', () => {
|
||||
const { container } = render(<ActiveFilterChips />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a status chip with the correct aria-label and label', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: status' });
|
||||
expect(chip).toHaveTextContent('Status: Failed');
|
||||
});
|
||||
|
||||
it('renders a job-type chip using JOB_TYPE_LABELS for the display label', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, type: 'search_indexers' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: job type' });
|
||||
expect(chip).toHaveTextContent('Type: Search Indexers');
|
||||
});
|
||||
|
||||
it('renders an Errors only chip when hasError is true', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, hasError: true };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: errors only' });
|
||||
expect(chip).toHaveTextContent('Errors only');
|
||||
});
|
||||
|
||||
it('clicking a chip calls removeFilter with the correct key', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' };
|
||||
render(<ActiveFilterChips />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove filter: status' }));
|
||||
expect(removeFilterMock).toHaveBeenCalledWith('status');
|
||||
});
|
||||
|
||||
it('clicking the date chip clears both dateFrom and dateTo via setFilters', () => {
|
||||
mockFilters = {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
dateFrom: '2026-05-10T00:00:00.000Z',
|
||||
dateTo: '2026-05-12T00:00:00.000Z',
|
||||
};
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: date range' });
|
||||
fireEvent.click(chip);
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ dateFrom: null, dateTo: null });
|
||||
});
|
||||
|
||||
it('renders a search chip when search is non-empty', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, search: 'timeout' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: search' });
|
||||
expect(chip).toHaveTextContent('Search: "timeout"');
|
||||
fireEvent.click(chip);
|
||||
expect(removeFilterMock).toHaveBeenCalledWith('search');
|
||||
});
|
||||
|
||||
it('user chip uses resolved plexUsername when available, falls back to id', () => {
|
||||
findUserByIdMock.mockReturnValue({ id: 'user-1', plexUsername: 'alice', role: 'user' });
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, userId: 'user-1' };
|
||||
const { unmount } = render(<ActiveFilterChips />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove filter: user' })
|
||||
).toHaveTextContent('User: alice');
|
||||
unmount();
|
||||
|
||||
findUserByIdMock.mockReturnValue(undefined);
|
||||
render(<ActiveFilterChips />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove filter: user' })
|
||||
).toHaveTextContent('User: user-1');
|
||||
});
|
||||
|
||||
it('audiobook chip shows the query string', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, audiobookQuery: 'Dune' };
|
||||
render(<ActiveFilterChips />);
|
||||
const chip = screen.getByRole('button', { name: 'Remove filter: audiobook' });
|
||||
expect(chip).toHaveTextContent('Book: "Dune"');
|
||||
});
|
||||
|
||||
it('renders all chips together when multiple filters are active', () => {
|
||||
mockFilters = {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
status: 'failed',
|
||||
type: 'search_indexers',
|
||||
hasError: true,
|
||||
search: 'oops',
|
||||
audiobookQuery: 'Dune',
|
||||
};
|
||||
render(<ActiveFilterChips />);
|
||||
const group = screen.getByRole('group', { name: 'Active filters' });
|
||||
// Five chips for five active values.
|
||||
expect(group.querySelectorAll('button')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Component: Admin Logs — LogsFilters Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import LogsFilters from '@/app/admin/logs/components/LogsFilters';
|
||||
import { DEFAULT_FILTER_STATE, type LogsFilterState } from '@/app/admin/logs/types';
|
||||
|
||||
// ---- Mock hooks (the seam between components and page-level state) -------
|
||||
|
||||
const setFiltersMock = vi.fn();
|
||||
const clearAllMock = vi.fn();
|
||||
const removeFilterMock = vi.fn();
|
||||
const setSearchInputMock = vi.fn();
|
||||
let mockFilters: LogsFilterState = { ...DEFAULT_FILTER_STATE };
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useLogsUrlState', () => ({
|
||||
useLogsUrlState: () => ({
|
||||
filters: mockFilters,
|
||||
setFilters: setFiltersMock,
|
||||
setSearchInput: setSearchInputMock,
|
||||
searchInput: mockFilters.search,
|
||||
clearAll: clearAllMock,
|
||||
removeFilter: removeFilterMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const registerMock = vi.fn();
|
||||
const unregisterMock = vi.fn();
|
||||
const useRegisterPauseReasonMock = vi.fn();
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useAutoRefreshControl', () => ({
|
||||
useAutoRefreshControl: () => ({
|
||||
register: registerMock,
|
||||
unregister: unregisterMock,
|
||||
isPaused: false,
|
||||
isRunning: true,
|
||||
pauseReasons: [],
|
||||
enabled: true,
|
||||
setEnabled: vi.fn(),
|
||||
effectiveInterval: 10000,
|
||||
manualRefresh: vi.fn(),
|
||||
setMutate: vi.fn(),
|
||||
setLastUpdatedAt: vi.fn(),
|
||||
lastUpdatedAt: 0,
|
||||
}),
|
||||
useRegisterPauseReason: (reason: string, active: boolean) => {
|
||||
useRegisterPauseReasonMock(reason, active);
|
||||
React.useEffect(() => {
|
||||
if (active) registerMock(reason);
|
||||
else unregisterMock(reason);
|
||||
return () => unregisterMock(reason);
|
||||
}, [reason, active]);
|
||||
},
|
||||
}));
|
||||
|
||||
const filterByQueryMock = vi.fn();
|
||||
const findUserByIdMock = vi.fn();
|
||||
|
||||
vi.mock('@/app/admin/logs/hooks/useUserSearch', () => ({
|
||||
useUserSearch: () => ({
|
||||
users: [
|
||||
{ id: 'user-1', plexUsername: 'alice', role: 'user' },
|
||||
{ id: 'user-2', plexUsername: 'bob', role: 'admin' },
|
||||
],
|
||||
filterByQuery: filterByQueryMock,
|
||||
findUserById: findUserByIdMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// ---- Tests ---------------------------------------------------------------
|
||||
|
||||
describe('LogsFilters', () => {
|
||||
beforeEach(() => {
|
||||
setFiltersMock.mockReset();
|
||||
clearAllMock.mockReset();
|
||||
removeFilterMock.mockReset();
|
||||
registerMock.mockReset();
|
||||
unregisterMock.mockReset();
|
||||
useRegisterPauseReasonMock.mockReset();
|
||||
filterByQueryMock.mockReset();
|
||||
findUserByIdMock.mockReset();
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE };
|
||||
filterByQueryMock.mockReturnValue([
|
||||
{ id: 'user-1', plexUsername: 'alice', role: 'user' },
|
||||
{ id: 'user-2', plexUsername: 'bob', role: 'admin' },
|
||||
]);
|
||||
findUserByIdMock.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('renders the Status dropdown with all canonical options', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Status') as HTMLSelectElement;
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values).toEqual(['all', 'pending', 'active', 'completed', 'failed', 'delayed', 'stuck']);
|
||||
});
|
||||
|
||||
it('renders the Job Type dropdown with All Types + JOB_TYPE_LABELS in insertion order', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Job Type') as HTMLSelectElement;
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
// First option is 'all', followed by every JOB_TYPE_LABELS key.
|
||||
expect(values[0]).toBe('all');
|
||||
expect(values.slice(1, 5)).toEqual([
|
||||
'search_indexers',
|
||||
'download_torrent',
|
||||
'monitor_download',
|
||||
'organize_files',
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls setFilters({ status }) when the Status dropdown changes', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Status') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'failed' } });
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ status: 'failed' });
|
||||
});
|
||||
|
||||
it('clicking a preset date option calls setFilters with computed dateFrom and dateTo null', () => {
|
||||
render(<LogsFilters />);
|
||||
const dateSelect = screen.getByLabelText('Date Range') as HTMLSelectElement;
|
||||
fireEvent.change(dateSelect, { target: { value: 'last_7d' } });
|
||||
expect(setFiltersMock).toHaveBeenCalledTimes(1);
|
||||
const [call] = setFiltersMock.mock.calls;
|
||||
const payload = call[0] as Partial<LogsFilterState>;
|
||||
expect(payload.dateTo).toBeNull();
|
||||
expect(payload.dateFrom).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
const fromMs = new Date(payload.dateFrom as string).getTime();
|
||||
// 7 days ago, ±60s tolerance (test execution time).
|
||||
const expected = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
expect(Math.abs(fromMs - expected)).toBeLessThan(60_000);
|
||||
});
|
||||
|
||||
it('selecting Custom reveals datetime-local inputs', () => {
|
||||
render(<LogsFilters />);
|
||||
const dateSelect = screen.getByLabelText('Date Range') as HTMLSelectElement;
|
||||
fireEvent.change(dateSelect, { target: { value: 'custom' } });
|
||||
expect(screen.getByLabelText('Date from')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Date to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('typing in the Audiobook input calls setFilters with audiobookQuery', () => {
|
||||
render(<LogsFilters />);
|
||||
const input = screen.getByLabelText('Audiobook') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'Dune' } });
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ audiobookQuery: 'Dune' });
|
||||
});
|
||||
|
||||
it('user typeahead selection calls setFilters with the user id', () => {
|
||||
render(<LogsFilters />);
|
||||
const input = screen.getByLabelText('User') as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: 'al' } });
|
||||
// The popover renders the filtered options; click "alice" via mouseDown
|
||||
// (the component uses onMouseDown to avoid the blur race).
|
||||
const option = screen.getByRole('option', { name: /alice/ });
|
||||
fireEvent.mouseDown(option);
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('user typeahead clear button calls setFilters with userId null', () => {
|
||||
findUserByIdMock.mockReturnValue({ id: 'user-1', plexUsername: 'alice', role: 'user' });
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, userId: 'user-1' };
|
||||
render(<LogsFilters />);
|
||||
const clear = screen.getByRole('button', { name: 'Clear user filter' });
|
||||
fireEvent.click(clear);
|
||||
expect(setFiltersMock).toHaveBeenCalledWith({ userId: null });
|
||||
});
|
||||
|
||||
it('hides "Clear all filters" when no filters or search are active', () => {
|
||||
render(<LogsFilters />);
|
||||
expect(screen.queryByText('Clear all filters')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Clear all filters" when at least one filter is active and clears on click', () => {
|
||||
mockFilters = { ...DEFAULT_FILTER_STATE, status: 'failed' };
|
||||
render(<LogsFilters />);
|
||||
const button = screen.getByText('Clear all filters');
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
expect(clearAllMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('registers a pause reason when the Status select is focused and unregisters on blur', () => {
|
||||
render(<LogsFilters />);
|
||||
const select = screen.getByLabelText('Status') as HTMLSelectElement;
|
||||
fireEvent.focus(select);
|
||||
expect(registerMock).toHaveBeenCalledWith('logs-status-dropdown');
|
||||
fireEvent.blur(select);
|
||||
expect(unregisterMock).toHaveBeenCalledWith('logs-status-dropdown');
|
||||
});
|
||||
|
||||
it('custom datetime-local input emits UTC ISO via setFilters', () => {
|
||||
render(<LogsFilters />);
|
||||
fireEvent.change(screen.getByLabelText('Date Range'), { target: { value: 'custom' } });
|
||||
const fromInput = screen.getByLabelText('Date from') as HTMLInputElement;
|
||||
fireEvent.change(fromInput, { target: { value: '2026-01-15T10:30' } });
|
||||
expect(setFiltersMock).toHaveBeenCalled();
|
||||
const lastCall = setFiltersMock.mock.calls.at(-1)?.[0] as Partial<LogsFilterState>;
|
||||
expect(lastCall.dateFrom).toMatch(/Z$/);
|
||||
// The submitted ISO must parse to the same wall-clock time the user typed,
|
||||
// interpreted as local. Round-trip check:
|
||||
const parsed = new Date(lastCall.dateFrom as string);
|
||||
const localRoundTrip = new Date(2026, 0, 15, 10, 30);
|
||||
expect(parsed.getTime()).toBe(localRoundTrip.getTime());
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,23 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AdminLogsPage from '@/app/admin/logs/page';
|
||||
import {
|
||||
buildLogsApiKey,
|
||||
DEFAULT_FILTER_STATE,
|
||||
LogsData,
|
||||
LogsFilterState,
|
||||
} from '@/app/admin/logs/types';
|
||||
|
||||
// ===========================================================================
|
||||
// Mocks
|
||||
// ===========================================================================
|
||||
|
||||
const useSWRMock = vi.hoisted(() => vi.fn());
|
||||
const routerReplaceMock = vi.hoisted(() => vi.fn());
|
||||
const searchParamsState = vi.hoisted(() => ({ value: new URLSearchParams() }));
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: (...args: any[]) => useSWRMock(...args),
|
||||
@@ -20,108 +32,562 @@ vi.mock('@/lib/utils/api', () => ({
|
||||
authenticatedFetcher: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AdminLogsPage', () => {
|
||||
beforeEach(() => {
|
||||
useSWRMock.mockReset();
|
||||
});
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace: routerReplaceMock, push: vi.fn() }),
|
||||
usePathname: () => '/admin/logs',
|
||||
useSearchParams: () => searchParamsState.value,
|
||||
}));
|
||||
|
||||
it('renders logs and toggles detail rows', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: {
|
||||
logs: [
|
||||
{
|
||||
id: 'log-1',
|
||||
bullJobId: 'bull-1',
|
||||
type: 'search_indexers',
|
||||
status: 'failed',
|
||||
priority: 1,
|
||||
attempts: 2,
|
||||
maxAttempts: 3,
|
||||
errorMessage: 'Search failed',
|
||||
startedAt: '2024-01-01T00:00:00Z',
|
||||
completedAt: '2024-01-01T00:02:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:02:00Z',
|
||||
result: { retries: 2 },
|
||||
events: [
|
||||
{
|
||||
id: 'event-1',
|
||||
level: 'error',
|
||||
context: 'SearchJob',
|
||||
message: 'Indexer timeout',
|
||||
metadata: { indexer: 'Example' },
|
||||
createdAt: '2024-01-01T00:01:00Z',
|
||||
},
|
||||
],
|
||||
request: {
|
||||
id: 'req-1',
|
||||
audiobook: { title: 'Search Book', author: 'Author' },
|
||||
user: { plexUsername: 'User' },
|
||||
},
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 50, total: 1, totalPages: 1 },
|
||||
// useUserSearch fires its own SWR call for /api/admin/users; we branch the
|
||||
// SWR mock by URL so both the logs key and the users key get sensible defaults.
|
||||
const mockMutate = vi.fn();
|
||||
function defaultSwrImpl(logsResponse: { data?: LogsData; error?: Error } = {}) {
|
||||
return (key: string) => {
|
||||
if (typeof key === 'string' && key.startsWith('/api/admin/users')) {
|
||||
return { data: { users: [] }, error: undefined, mutate: vi.fn(), isLoading: false };
|
||||
}
|
||||
return {
|
||||
data: logsResponse.data,
|
||||
error: logsResponse.error,
|
||||
mutate: mockMutate,
|
||||
isLoading: false,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Fixtures
|
||||
// ===========================================================================
|
||||
|
||||
function makeLog(overrides: Partial<any> = {}): any {
|
||||
return {
|
||||
id: 'log-1',
|
||||
bullJobId: 'bull-1',
|
||||
type: 'search_indexers',
|
||||
status: 'failed',
|
||||
priority: 1,
|
||||
attempts: 2,
|
||||
maxAttempts: 3,
|
||||
errorMessage: 'Search failed',
|
||||
startedAt: '2024-01-01T00:00:00Z',
|
||||
completedAt: '2024-01-01T00:02:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:02:00Z',
|
||||
result: { retries: 2 },
|
||||
events: [
|
||||
{
|
||||
id: 'event-1',
|
||||
level: 'error',
|
||||
context: 'SearchJob',
|
||||
message: 'Indexer timeout',
|
||||
metadata: { indexer: 'Example' },
|
||||
createdAt: '2024-01-01T00:01:00Z',
|
||||
},
|
||||
error: undefined,
|
||||
}));
|
||||
],
|
||||
request: {
|
||||
id: 'req-1',
|
||||
audiobook: { title: 'Search Book', author: 'Author' },
|
||||
user: { plexUsername: 'User' },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeData(logs: any[] = [makeLog()], pagination: Partial<any> = {}): LogsData {
|
||||
return {
|
||||
logs,
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: logs.length,
|
||||
totalPages: Math.max(1, Math.ceil(logs.length / 50)),
|
||||
...pagination,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Setup / teardown
|
||||
// ===========================================================================
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
useSWRMock.mockReset();
|
||||
routerReplaceMock.mockReset();
|
||||
mockMutate.mockReset();
|
||||
searchParamsState.value = new URLSearchParams();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
try {
|
||||
window.sessionStorage.clear();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Page-level tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('AdminLogsPage', () => {
|
||||
it('renders the page header and a desktop row from data', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText('System Logs')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Search Book')[0]).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Show Details' })[0]);
|
||||
expect(screen.getAllByText('Event Log')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Job Result')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Error')[0]).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Hide Details' })[0]);
|
||||
expect(screen.queryByText('Event Log')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the swr key when filters change', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
it('shows skeleton on initial load (no data, no error)', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: undefined, error: undefined }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const statusSelect = screen
|
||||
.getByText('Status', { selector: 'label' })
|
||||
.parentElement?.querySelector('select');
|
||||
expect(statusSelect).not.toBeNull();
|
||||
fireEvent.change(statusSelect as HTMLSelectElement, { target: { value: 'completed' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
'/api/admin/logs?page=1&limit=50&status=completed&type=all',
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
expect(await screen.findByTestId('log-skeleton-mobile')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('log-skeleton-desktop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state when logs fail to load', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: undefined,
|
||||
error: new Error('Log failure'),
|
||||
}));
|
||||
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: undefined, error: new Error('Log failure') })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText('Error Loading Logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log failure')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no logs are returned', async () => {
|
||||
useSWRMock.mockImplementation(() => ({
|
||||
data: { logs: [], pagination: { page: 1, limit: 50, total: 0, totalPages: 1 } },
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
it('uses buildLogsApiKey for the SWR key (with hydrate-time 7d default applied)', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect((await screen.findAllByText('No logs found'))[0]).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const key = calls[0][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
// Defaults: page=1, limit=50 always present.
|
||||
expect(params.get('page')).toBe('1');
|
||||
expect(params.get('limit')).toBe('50');
|
||||
// Zach Resolution #1: hydrate-time Last-7-days default → dateFrom set,
|
||||
// dateTo unset (sliding window to "now").
|
||||
const dateFrom = params.get('dateFrom');
|
||||
expect(dateFrom).not.toBeNull();
|
||||
expect(params.get('dateTo')).toBeNull();
|
||||
// Confirm the dateFrom is roughly 7 days ago (allow generous tolerance).
|
||||
const fromMs = new Date(dateFrom as string).getTime();
|
||||
const expected = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
expect(Math.abs(fromMs - expected)).toBeLessThan(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the SWR key when Errors-only pill is activated', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const pill = await screen.findByRole('button', { name: /errors only/i });
|
||||
fireEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerReplaceMock).toHaveBeenCalled();
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toContain('hasError=1');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders fresh empty state when no rows, no filters, no search', async () => {
|
||||
// Note: hydrate-time 7d default IS applied here, but the page's
|
||||
// empty-state branch treats the implicit default as "not user-applied",
|
||||
// so the "fresh" copy still wins.
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/No background jobs have run yet/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('skips hydrate-time 7d default when URL already has dateFrom', async () => {
|
||||
searchParamsState.value = new URLSearchParams('dateFrom=2024-01-01T00:00:00.000Z');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const key = calls[calls.length - 1][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
// URL-provided dateFrom wins; hydrate default does NOT replace it.
|
||||
expect(params.get('dateFrom')).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
it('retires hydrate default after user retires dates via setFilters', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
// First confirm hydrate is active.
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const key = calls[calls.length - 1][0];
|
||||
expect(new URLSearchParams(key.split('?')[1]).get('dateFrom')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Click Errors-only — this writes URL. The hydrate dates ride along in
|
||||
// the merge, so URL now carries an explicit dateFrom.
|
||||
const pill = await screen.findByRole('button', { name: /errors only/i });
|
||||
fireEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerReplaceMock).toHaveBeenCalled();
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
const params = new URLSearchParams((lastCall[0] as string).split('?')[1] ?? '');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('dateFrom')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders search-no-match empty state with Clear search button', async () => {
|
||||
searchParamsState.value = new URLSearchParams('search=foo');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(await screen.findByText(/No matches for/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear search and show all logs/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders filters-too-tight empty state with Clear filters button', async () => {
|
||||
searchParamsState.value = new URLSearchParams('status=failed');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/No logs match your current filters/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates filter state from URL on mount', async () => {
|
||||
searchParamsState.value = new URLSearchParams('status=failed&hasError=1&page=2');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const key = calls[calls.length - 1][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
expect(params.get('status')).toBe('failed');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('page')).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('silently drops invalid URL params', async () => {
|
||||
searchParamsState.value = new URLSearchParams(
|
||||
'status=garbage&type=not_a_type&limit=37&page=abc'
|
||||
);
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const key = calls[calls.length - 1][0];
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
// Invalid values silently dropped → defaults applied
|
||||
expect(params.get('status')).toBeNull();
|
||||
expect(params.get('type')).toBeNull();
|
||||
// page + limit fall back to defaults (1 / 50) which the SWR key always sets
|
||||
expect(params.get('page')).toBe('1');
|
||||
expect(params.get('limit')).toBe('50');
|
||||
});
|
||||
});
|
||||
|
||||
it('debounces search input — fast keystrokes produce ONE URL write', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const search = await screen.findByLabelText(/search logs/i);
|
||||
fireEvent.change(search, { target: { value: 'a' } });
|
||||
fireEvent.change(search, { target: { value: 'ab' } });
|
||||
fireEvent.change(search, { target: { value: 'abc' } });
|
||||
|
||||
// Wait past the 300ms debounce window — only ONE URL write should land,
|
||||
// with the final value.
|
||||
await waitFor(
|
||||
() => {
|
||||
const searchCalls = routerReplaceMock.mock.calls.filter((c: any[]) =>
|
||||
(c[0] as string).includes('search=')
|
||||
);
|
||||
expect(searchCalls.length).toBe(1);
|
||||
expect(searchCalls[0][0]).toContain('search=abc');
|
||||
},
|
||||
{ timeout: 1500 }
|
||||
);
|
||||
});
|
||||
|
||||
it('shows search clear (×) when populated and clears search on click', async () => {
|
||||
searchParamsState.value = new URLSearchParams('search=foo');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
// The toolbar's × button has aria-label="Clear search" (exact match).
|
||||
const clearBtn = await screen.findByRole('button', { name: 'Clear search' });
|
||||
fireEvent.click(clearBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerReplaceMock).toHaveBeenCalled();
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).not.toContain('search=');
|
||||
});
|
||||
});
|
||||
|
||||
it('Refresh-now button triggers SWR mutate', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const refresh = await screen.findByRole('button', { name: /refresh now/i });
|
||||
fireEvent.click(refresh);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Auto-refresh toggle persists state to sessionStorage', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const toggle = await screen.findByRole('switch', { name: /auto-refresh/i });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
expect(window.sessionStorage.getItem('admin-logs:auto-refresh-enabled')).toBe('0');
|
||||
});
|
||||
|
||||
it('Auto-refresh OFF makes effectiveInterval=0 in the SWR call', async () => {
|
||||
window.sessionStorage.setItem('admin-logs:auto-refresh-enabled', '0');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const logsCalls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const lastCfg = logsCalls[logsCalls.length - 1][2];
|
||||
expect(lastCfg.refreshInterval).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('expanding a row pauses auto-refresh (refreshInterval=0)', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
fireEvent.click(discloseButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const logsCalls = useSWRMock.mock.calls.filter(
|
||||
(c: any[]) => typeof c[0] === 'string' && c[0].startsWith('/api/admin/logs')
|
||||
);
|
||||
const lastCfg = logsCalls[logsCalls.length - 1][2];
|
||||
expect(lastCfg.refreshInterval).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('Live indicator shows Paused when auto-refresh disabled', async () => {
|
||||
window.sessionStorage.setItem('admin-logs:auto-refresh-enabled', '0');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const indicator = screen.getByTestId('logs-live-indicator');
|
||||
expect(indicator.getAttribute('data-state')).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
it('pagination shows total result count and Page X of Y', async () => {
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: makeData([makeLog()], { total: 247, totalPages: 5 }) })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const summary = await screen.findByTestId('logs-pagination-summary');
|
||||
expect(summary.textContent).toContain('247');
|
||||
expect(summary.textContent).toMatch(/Page\s*1\s*of\s*5/);
|
||||
});
|
||||
|
||||
it('changing page-size triggers URL update with new limit and resets to page 1', async () => {
|
||||
searchParamsState.value = new URLSearchParams('page=3');
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: makeData([makeLog()], { page: 3, total: 200, totalPages: 4 }) })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const sizeSelect = await screen.findByLabelText(/page size/i);
|
||||
fireEvent.change(sizeSelect, { target: { value: '100' } });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toContain('limit=100');
|
||||
expect(lastCall[0]).not.toContain('page=');
|
||||
});
|
||||
});
|
||||
|
||||
it('changing a filter resets pagination to page 1', async () => {
|
||||
searchParamsState.value = new URLSearchParams('page=4');
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const pill = await screen.findByRole('button', { name: /errors only/i });
|
||||
fireEvent.click(pill);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
const params = new URLSearchParams((lastCall[0] as string).split('?')[1] ?? '');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('disclosure button has rotating chevron and ARIA expanded state', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
const button = discloseButtons[0];
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
const chevron = button.querySelector('svg');
|
||||
expect(chevron?.className.baseVal ?? chevron?.getAttribute('class') ?? '').not.toContain(
|
||||
'rotate-180'
|
||||
);
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||
const updatedChevron = button.querySelector('svg');
|
||||
const cls = updatedChevron?.className.baseVal ?? updatedChevron?.getAttribute('class') ?? '';
|
||||
expect(cls).toContain('rotate-180');
|
||||
});
|
||||
|
||||
it('detail panel shows Event Log / Job Result / Error sections when expanded', async () => {
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
fireEvent.click(discloseButtons[0]);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: /event log/i }).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('button', { name: /job result/i }).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /^error$/i }).length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('copy button on Bull Job ID calls clipboard and shows toast', async () => {
|
||||
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText: writeTextMock } });
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
|
||||
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData() }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const discloseButtons = await screen.findAllByRole('button', { name: /show details/i });
|
||||
fireEvent.click(discloseButtons[0]);
|
||||
|
||||
const copyButtons = screen.getAllByRole('button', { name: /copy bull job id/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(copyButtons[0]);
|
||||
});
|
||||
|
||||
expect(writeTextMock).toHaveBeenCalledWith('bull-1');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Copied Bull Job ID/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides disclosure button when log has no details', async () => {
|
||||
const log = makeLog({
|
||||
events: [],
|
||||
errorMessage: null,
|
||||
bullJobId: null,
|
||||
result: null,
|
||||
});
|
||||
useSWRMock.mockImplementation(defaultSwrImpl({ data: makeData([log]) }));
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
await screen.findAllByText('Search Book');
|
||||
expect(screen.queryByRole('button', { name: /show details/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('jump-to-page input on Enter dispatches a page change', async () => {
|
||||
useSWRMock.mockImplementation(
|
||||
defaultSwrImpl({ data: makeData([makeLog()], { total: 200, totalPages: 4 }) })
|
||||
);
|
||||
render(<AdminLogsPage />);
|
||||
|
||||
const jump = await screen.findByLabelText(/jump to page/i);
|
||||
fireEvent.change(jump, { target: { value: '3' } });
|
||||
fireEvent.keyDown(jump, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = routerReplaceMock.mock.calls[routerReplaceMock.mock.calls.length - 1];
|
||||
expect(lastCall[0]).toContain('page=3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// buildLogsApiKey unit tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('buildLogsApiKey', () => {
|
||||
it('omits defaults so the key stays short', () => {
|
||||
const key = buildLogsApiKey(DEFAULT_FILTER_STATE);
|
||||
const params = new URLSearchParams(key.split('?')[1] ?? '');
|
||||
expect(params.get('page')).toBe('1');
|
||||
expect(params.get('limit')).toBe('50');
|
||||
expect(params.get('status')).toBeNull();
|
||||
expect(params.get('type')).toBeNull();
|
||||
expect(params.get('search')).toBeNull();
|
||||
expect(params.get('hasError')).toBeNull();
|
||||
});
|
||||
|
||||
it('includes every active filter', () => {
|
||||
const state: LogsFilterState = {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
search: 'foo',
|
||||
status: 'failed',
|
||||
type: 'search_indexers',
|
||||
dateFrom: '2024-01-01T00:00:00Z',
|
||||
dateTo: '2024-01-02T00:00:00Z',
|
||||
hasError: true,
|
||||
userId: 'user-123',
|
||||
audiobookQuery: 'Mistborn',
|
||||
page: 2,
|
||||
limit: 100,
|
||||
};
|
||||
const params = new URLSearchParams(buildLogsApiKey(state).split('?')[1] ?? '');
|
||||
expect(params.get('search')).toBe('foo');
|
||||
expect(params.get('status')).toBe('failed');
|
||||
expect(params.get('type')).toBe('search_indexers');
|
||||
expect(params.get('dateFrom')).toBe('2024-01-01T00:00:00Z');
|
||||
expect(params.get('dateTo')).toBe('2024-01-02T00:00:00Z');
|
||||
expect(params.get('hasError')).toBe('1');
|
||||
expect(params.get('userId')).toBe('user-123');
|
||||
expect(params.get('audiobookQuery')).toBe('Mistborn');
|
||||
expect(params.get('page')).toBe('2');
|
||||
expect(params.get('limit')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user