Files
ReadMeABook/tests/api/requests.route.test.ts
T
kikootwo a81549768c Add paginated requests API and My Requests UI
Introduce cursor-based pagination and group counts for /api/requests (status groups, nextCursor, counts) and fetch one extra record to detect next page. Add a client-side My Requests experience: useSWRInfinite hook (useMyRequests) with smart polling for active requests, tabbed filters, badges, skeletons, load-more, and animated list entries. Update RequestCard and admin actions to treat awaiting_search as cancellable. Adjust Plex processors to ignore requests with status 'denied' when matching new media. Add static ffmpeg in the Docker image and remove preinstalled ImageMagick to avoid transitive deps. Update tests to account for pagination/take+1 and the new hook/UX behavior.
2026-03-06 10:41:17 -05:00

223 lines
7.1 KiB
TypeScript

/**
* Component: Requests API Route Tests
* Documentation: documentation/testing.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}),
}));
describe('Requests API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = {
user: { id: 'user-1', role: 'user' },
nextUrl: new URL('http://localhost/api/requests'),
json: vi.fn(),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 409 when an active request already exists', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-1', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'req-1',
status: 'downloaded',
userId: 'user-2',
user: { plexUsername: 'someone' },
} as any);
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(409);
expect(payload.error).toBe('BeingProcessed');
expect(findPlexMatchMock).not.toHaveBeenCalled();
});
it('returns 409 when a Plex match already exists', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-2', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce({ plexGuid: 'plex-1' });
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(409);
expect(payload.error).toBe('AlreadyAvailable');
});
it('creates a new request and enqueues a search job', async () => {
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-3', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce(null);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-1',
title: 'Title',
author: 'Author',
audibleAsin: 'ASIN-3',
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'user',
autoApproveRequests: true, // Auto-approve enabled for this user
plexId: 'user-1',
plexUsername: 'testuser',
plexEmail: null,
isSetupAdmin: false,
avatarUrl: null,
authToken: null,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: null,
plexHomeUserId: null,
authProvider: 'plex',
oidcSubject: null,
oidcProvider: null,
registrationStatus: 'approved',
bookDateLibraryScope: 'full',
bookDateCustomPrompt: null,
bookDateOnboardingComplete: false,
deletedAt: null,
deletedBy: null,
} as any);
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-2',
status: 'pending',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN-3' },
user: { id: 'user-1', plexUsername: 'user' },
} as any);
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);
const payload = await response.json();
expect(response.status).toBe(201);
expect(payload.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-2', {
id: 'ab-1',
title: 'Title',
author: 'Author',
asin: 'ASIN-3',
});
});
it('skips auto-search when skipAutoSearch=true', async () => {
authRequest.nextUrl = new URL('http://localhost/api/requests?skipAutoSearch=true');
authRequest.json.mockResolvedValue({
audiobook: { asin: 'ASIN-4', title: 'Title', author: 'Author' },
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce(null);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-2',
title: 'Title',
author: 'Author',
audibleAsin: 'ASIN-4',
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'user',
autoApproveRequests: true, // Auto-approve enabled for this user
plexId: 'user-1',
plexUsername: 'testuser',
plexEmail: null,
isSetupAdmin: false,
avatarUrl: null,
authToken: null,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: null,
plexHomeUserId: null,
authProvider: 'plex',
oidcSubject: null,
oidcProvider: null,
registrationStatus: 'approved',
bookDateLibraryScope: 'full',
bookDateCustomPrompt: null,
bookDateOnboardingComplete: false,
deletedAt: null,
deletedBy: null,
} as any);
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-3',
audiobook: { id: 'ab-2', title: 'Title', author: 'Author', audibleAsin: 'ASIN-4' },
user: { id: 'user-1', plexUsername: 'user' },
});
const { POST } = await import('@/app/api/requests/route');
await POST({} as any);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'awaiting_search' }),
})
);
});
it('filters requests for current user when not admin', async () => {
authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5');
prismaMock.request.findMany.mockResolvedValueOnce([]);
prismaMock.request.count.mockResolvedValue(0);
const { GET } = await import('@/app/api/requests/route');
const response = await GET({} as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: 'user-1', status: 'pending' }),
take: 6, // limit + 1 for cursor pagination next-page detection
})
);
});
});