diff --git a/tests/api/goodreads-shelves-id.routes.test.ts b/tests/api/goodreads-shelves-id.routes.test.ts new file mode 100644 index 0000000..97bff25 --- /dev/null +++ b/tests/api/goodreads-shelves-id.routes.test.ts @@ -0,0 +1,186 @@ +/** + * Component: Goodreads Shelves [id] API Route Tests + * Documentation: documentation/backend/services/goodreads-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()), +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +const SHELF = { + id: 'shelf-1', + userId: 'user-1', + name: 'Want to Read', + rssUrl: 'https://www.goodreads.com/review/list_rss/12345', + lastSyncAt: null, + bookCount: 5, + coverUrls: null, + createdAt: new Date().toISOString(), +}; + +describe('DELETE /api/user/goodreads-shelves/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'user' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when shelf does not exist', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('Shelf not found'); + }); + + it('returns 403 when shelf belongs to another user', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('deletes the shelf and returns success', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.delete.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.goodreadsShelf.delete).toHaveBeenCalledWith({ where: { id: 'shelf-1' } }); + }); + + it('returns 500 when deletion throws', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.delete.mockRejectedValueOnce(new Error('db error')); + + const { DELETE } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'shelf-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('Failed to delete shelf'); + }); +}); + +describe('PATCH /api/user/goodreads-shelves/[id]', () => { + const NEW_RSS = 'https://www.goodreads.com/review/list_rss/99999'; + + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 404 when shelf does not exist', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(null); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('Shelf not found'); + }); + + it('returns 403 when shelf belongs to another user', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce({ ...SHELF, userId: 'other-user' }); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('returns 400 for an invalid (non-URL) rssUrl', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: 'not-a-url' }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('updates the shelf, clears sync metadata, and triggers a sync job', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + const updatedShelf = { ...SHELF, rssUrl: NEW_RSS, lastSyncAt: null }; + prismaMock.goodreadsShelf.update.mockResolvedValueOnce(updatedShelf); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.goodreadsShelf.update).toHaveBeenCalledWith({ + where: { id: 'shelf-1' }, + data: { rssUrl: NEW_RSS, lastSyncAt: null, bookCount: null, coverUrls: null }, + }); + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, updatedShelf.id, 'goodreads', 0); + }); + + it('still returns 200 even when the sync job fails to enqueue', async () => { + prismaMock.goodreadsShelf.findUnique.mockResolvedValueOnce(SHELF); + prismaMock.goodreadsShelf.update.mockResolvedValueOnce({ ...SHELF, rssUrl: NEW_RSS }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { PATCH } = await import('@/app/api/user/goodreads-shelves/[id]/route'); + const response = await PATCH( + { json: vi.fn().mockResolvedValue({ rssUrl: NEW_RSS }) } as any, + { params: Promise.resolve({ id: 'shelf-1' }) } + ); + const payload = await response.json(); + + // Sync job failure is swallowed; shelf update should still succeed + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); +}); diff --git a/tests/api/hardcover-shelves-id.routes.test.ts b/tests/api/hardcover-shelves-id.routes.test.ts new file mode 100644 index 0000000..338ce2d --- /dev/null +++ b/tests/api/hardcover-shelves-id.routes.test.ts @@ -0,0 +1,222 @@ +/** + * 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:', '')), +})); + +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, +})); + +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)); + }); + + 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); + }); +}); diff --git a/tests/api/hardcover-shelves.routes.test.ts b/tests/api/hardcover-shelves.routes.test.ts new file mode 100644 index 0000000..fae8242 --- /dev/null +++ b/tests/api/hardcover-shelves.routes.test.ts @@ -0,0 +1,216 @@ +/** + * Component: Hardcover Shelves API Route Tests (POST / GET) + * 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:', '')), +})); +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-sync.service', () => ({ + fetchHardcoverList: fetchHardcoverListMock, +})); + +const FETCHED_LIST = { + listName: 'Currently Reading', + books: [ + { title: 'Dune', author: 'Frank Herbert', coverUrl: 'https://example.com/dune.jpg' }, + { title: 'Foundation', author: 'Isaac Asimov', coverUrl: null }, + ], +}; + +describe('POST /api/user/hardcover-shelves', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn().mockResolvedValue({ listId: 'status-2', apiToken: 'raw-token' }), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 400 when apiToken is missing', async () => { + authRequest.json.mockResolvedValueOnce({ listId: 'status-2' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 400 when listId is missing', async () => { + authRequest.json.mockResolvedValueOnce({ apiToken: 'raw-token' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 409 when the list is already subscribed', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce({ id: 'existing-shelf' }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('DuplicateShelf'); + }); + + it('returns 400 when Hardcover API fetch fails', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockRejectedValueOnce(new Error('Invalid token')); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('InvalidHardcoverList'); + expect(payload.message).toContain('Invalid token'); + }); + + it('creates the shelf with an encrypted token and triggers sync', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(payload.shelf.name).toBe('Currently Reading'); + + // Token must have been encrypted before storage + expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token'); + expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + apiToken: 'enc:raw-token', + listId: 'status-2', + userId: 'user-1', + }), + }) + ); + + // Immediate background sync must have been triggered + expect(jobQueueMock.addSyncShelvesJob).toHaveBeenCalledWith(undefined, 'new-shelf', 'hardcover', 0); + }); + + it('strips Bearer prefix from apiToken before encrypting', async () => { + authRequest.json.mockResolvedValueOnce({ listId: 'status-2', apiToken: 'Bearer raw-token' }); + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-2', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + await POST({} as any); + + // "Bearer " prefix must have been stripped before encrypt was called + expect(encryptionMock.encrypt).toHaveBeenCalledWith('raw-token'); + }); + + it('returns 201 even when the sync job fails to enqueue', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-3', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + jobQueueMock.addSyncShelvesJob.mockRejectedValueOnce(new Error('queue down')); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + }); + + it('only includes books with cover URLs in the initial shelf preview', async () => { + prismaMock.hardcoverShelf.findUnique.mockResolvedValueOnce(null); + fetchHardcoverListMock.mockResolvedValueOnce(FETCHED_LIST); // only 1 of 2 books has coverUrl + prismaMock.hardcoverShelf.create.mockResolvedValueOnce({ + id: 'new-shelf-4', + name: 'Currently Reading', + listId: 'status-2', + lastSyncAt: null, + createdAt: new Date().toISOString(), + bookCount: 2, + coverUrls: null, + }); + + const { POST } = await import('@/app/api/user/hardcover-shelves/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + // The coverUrls stored should only include books with non-null coverUrl + expect(prismaMock.hardcoverShelf.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + // 1 book has cover, 1 doesn't → only 1 stored + coverUrls: JSON.stringify([ + { coverUrl: 'https://example.com/dune.jpg', asin: null, title: 'Dune', author: 'Frank Herbert' }, + ]), + }), + }) + ); + }); +}); diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index fc551c1..6dfc5a1 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -47,6 +47,7 @@ export const createPrismaMock = () => ({ bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), goodreadsBookMapping: createModelMock(), + hardcoverShelf: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), });