mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user