mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
eef6ae3462
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.
255 lines
9.8 KiB
TypeScript
255 lines
9.8 KiB
TypeScript
/**
|
|
* Component: Admin Logs API Route Tests
|
|
* Documentation: documentation/testing.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPrismaMock } from '../helpers/prisma';
|
|
|
|
let authRequest: any;
|
|
|
|
const prismaMock = createPrismaMock();
|
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
|
const requireAdminMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/middleware/auth', () => ({
|
|
requireAuth: requireAuthMock,
|
|
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();
|
|
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
|
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
|
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
|
});
|
|
|
|
it('returns paginated logs', async () => {
|
|
prismaMock.job.findMany.mockResolvedValueOnce([{ id: 'job-1' }]);
|
|
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=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);
|
|
});
|
|
});
|
|
});
|