Files
ReadMeABook/tests/api/hardcover-shelves-id.routes.test.ts
T
kikootwo 7f706e806f Use hardcover-api service with pagination
Replace the old hardcover sync usage with a new hardcover-api.service implementation that adds types, a reusable extractBooks helper, and paginated GraphQL queries (limit/offset) to fully fetch status and list books. Update API route import to use the new service. Fix ManageShelfModal to initialize rssUrl/listId as empty strings. Update tests to mock the new service and add encryption format helper mocking.
2026-03-04 10:28:52 -05:00

234 lines
8.9 KiB
TypeScript

/**
* Component: Hardcover Shelves [id] API Route Tests
* Documentation: documentation/backend/services/hardcover-sync.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(() => ({
addSyncShelvesJob: vi.fn(() => Promise.resolve()),
}));
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((s: string) => `enc:${s}`),
decrypt: vi.fn((s: string) => s.replace('enc:', '')),
isEncryptedFormat: vi.fn((s: string) => s.startsWith('enc:')),
}));
const fetchHardcoverListMock = 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/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
vi.mock('@/lib/services/hardcover-api.service', () => ({
fetchHardcoverList: fetchHardcoverListMock,
}));
const SHELF = {
id: 'hc-shelf-1',
userId: 'user-1',
name: 'Currently Reading',
listId: 'status-2',
apiToken: 'enc:secret-token',
lastSyncAt: null,
bookCount: 3,
coverUrls: null,
createdAt: new Date().toISOString(),
};
describe('DELETE /api/user/hardcover-shelves/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
});
it('returns 404 when list does not exist', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('List not found');
});
it('returns 403 when list belongs to another user', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('deletes the list and returns success', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.delete.mockResolvedValueOnce({});
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.hardcoverShelf.delete).toHaveBeenCalledWith({ where: { id: 'hc-shelf-1' } });
});
it('returns 500 when deletion throws', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.delete.mockRejectedValueOnce(new Error('db error'));
const { DELETE } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'hc-shelf-1' }) });
const payload = await response.json();
expect(response.status).toBe(500);
expect(payload.error).toBe('Failed to delete list');
});
});
describe('PATCH /api/user/hardcover-shelves/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
encryptionMock.isEncryptedFormat.mockImplementation((s: string) => s.startsWith('enc:'));
encryptionMock.encrypt.mockImplementation((s: string) => `enc:${s}`);
encryptionMock.decrypt.mockImplementation((s: string) => s.replace('enc:', ''));
fetchHardcoverListMock.mockResolvedValue({ listName: 'Test List', books: [] });
});
it('returns 404 when list does not exist', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('List not found');
});
it('returns 403 when list belongs to another user', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' });
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('Forbidden');
});
it('does not trigger a sync when no fields changed', async () => {
// listId is the same as existing; no apiToken provided
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: SHELF.listId }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(jobQueueMock.addSyncShelvesJob).not.toHaveBeenCalled();
});
it('updates listId, clears metadata, and triggers a sync job', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
const updated = { ...SHELF, listId: 'status-3', lastSyncAt: null };
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(updated);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
where: { id: 'hc-shelf-1' },
data: expect.objectContaining({ listId: 'status-3', lastSyncAt: null }),
});
expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updated.id, 'hardcover', 0);
});
it('encrypts the apiToken before persisting', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
await PATCH(
{ json: vi.fn().mockResolvedValue({ apiToken: 'my-raw-token' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token');
expect(prismaMock.hardcoverShelf.update).toHaveBeenCalledWith({
where: { id: 'hc-shelf-1' },
data: expect.objectContaining({ apiToken: 'enc:my-raw-token' }),
});
});
it('strips the Bearer prefix before encrypting the token', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce(SHELF);
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
await PATCH(
{ json: vi.fn().mockResolvedValue({ apiToken: 'Bearer my-raw-token' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
expect(encryptionMock.encrypt).toHaveBeenCalledWith('my-raw-token');
});
it('still returns 200 even when the sync job fails to enqueue', async () => {
prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(SHELF);
prismaMock.hardcoverShelf.update.mockResolvedValueOnce({ ...SHELF, listId: 'status-3' });
jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down'));
const { PATCH } = await import('@/app/api/user/hardcover-shelves/[id]/route');
const response = await PATCH(
{ json: vi.fn().mockResolvedValue({ listId: 'status-3' }) } as any,
{ params: Promise.resolve({ id: 'hc-shelf-1' }) }
);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
});
});