mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
94dbaf073b
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
656 lines
21 KiB
TypeScript
656 lines
21 KiB
TypeScript
/**
|
|
* Component: BookDate Helper Tests
|
|
* Documentation: documentation/features/bookdate-prd.md
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPrismaMock } from '../helpers/prisma';
|
|
|
|
const prismaMock = createPrismaMock();
|
|
const configMock = vi.hoisted(() => ({
|
|
getBackendMode: vi.fn(),
|
|
get: vi.fn(),
|
|
getPlexConfig: vi.fn(),
|
|
}));
|
|
const encryptionMock = vi.hoisted(() => ({
|
|
decrypt: vi.fn(),
|
|
}));
|
|
const plexMock = vi.hoisted(() => ({
|
|
getServerAccessToken: vi.fn(),
|
|
getLibraryContent: vi.fn(),
|
|
}));
|
|
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
|
const loggerMock = vi.hoisted(() => ({
|
|
create: vi.fn(),
|
|
}));
|
|
const audibleState = vi.hoisted(() => ({
|
|
instance: {
|
|
search: vi.fn(),
|
|
getAudiobookDetails: vi.fn(),
|
|
},
|
|
ctor: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/encryption.service', () => ({
|
|
getEncryptionService: () => encryptionMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/plex.service', () => ({
|
|
getPlexService: () => plexMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/integrations/audible.service', () => ({
|
|
AudibleService: audibleState.ctor,
|
|
}));
|
|
|
|
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
|
findPlexMatch: findPlexMatchMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/utils/logger', () => ({
|
|
RMABLogger: {
|
|
create: loggerMock.create,
|
|
},
|
|
}));
|
|
|
|
describe('BookDate helpers', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
loggerMock.create.mockReturnValue({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
});
|
|
audibleState.ctor.mockImplementation(function () {
|
|
return audibleState.instance;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('returns empty library when audiobookshelf has no library id', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
configMock.get.mockResolvedValue(null);
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'rated');
|
|
|
|
expect(result).toEqual([]);
|
|
expect(prismaMock.plexLibrary.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('maps audiobookshelf cached books without ratings', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
configMock.get.mockResolvedValue('abs-lib-1');
|
|
prismaMock.user.findUnique.mockResolvedValue({ plexId: 'local-1' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: 'Narr',
|
|
plexGuid: 'guid',
|
|
plexRatingKey: 'rk',
|
|
userRating: '7',
|
|
},
|
|
]);
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'full');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: 'Narr',
|
|
rating: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('returns rated books for local admin Plex users', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
|
prismaMock.user.findUnique
|
|
.mockResolvedValueOnce({ plexId: 'local-1' })
|
|
.mockResolvedValueOnce({ authToken: 'token', plexId: 'local-1', role: 'admin' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Rated',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid-1',
|
|
plexRatingKey: 'rk-1',
|
|
userRating: '9',
|
|
},
|
|
{
|
|
title: 'Unrated',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid-2',
|
|
plexRatingKey: 'rk-2',
|
|
userRating: null,
|
|
},
|
|
]);
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'rated');
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].title).toBe('Rated');
|
|
expect(result[0].rating).toBe(9);
|
|
});
|
|
|
|
it('returns rated books for Plex users with personal ratings', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getPlexConfig.mockResolvedValue({
|
|
libraryId: 'plex-lib',
|
|
serverUrl: 'http://plex',
|
|
machineIdentifier: 'machine',
|
|
});
|
|
prismaMock.user.findUnique
|
|
.mockResolvedValueOnce({ plexId: 'plex-1' })
|
|
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Rated Book',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid-1',
|
|
plexRatingKey: 'rk-1',
|
|
userRating: null,
|
|
},
|
|
{
|
|
title: 'Unrated',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid-2',
|
|
plexRatingKey: 'rk-2',
|
|
userRating: null,
|
|
},
|
|
]);
|
|
encryptionMock.decrypt.mockReturnValue('user-token');
|
|
plexMock.getServerAccessToken.mockResolvedValue('server-token');
|
|
plexMock.getLibraryContent.mockResolvedValue([
|
|
{ guid: 'guid-1', ratingKey: 'rk-1', userRating: 8 },
|
|
{ guid: 'guid-2', ratingKey: 'rk-2' },
|
|
]);
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'rated');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
title: 'Rated Book',
|
|
author: 'Author',
|
|
narrator: undefined,
|
|
rating: 8,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('falls back to cached books when user token is missing', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
|
prismaMock.user.findUnique
|
|
.mockResolvedValueOnce({ plexId: 'plex-2' })
|
|
.mockResolvedValueOnce({ authToken: null, plexId: 'plex-2', role: 'user' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid',
|
|
plexRatingKey: 'rk',
|
|
userRating: null,
|
|
},
|
|
]);
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'full');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: undefined,
|
|
rating: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('returns empty list when Plex library id is missing', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'full');
|
|
|
|
expect(result).toEqual([]);
|
|
expect(prismaMock.plexLibrary.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('falls back to cached books when Plex server URL is missing', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
|
prismaMock.user.findUnique
|
|
.mockResolvedValueOnce({ plexId: 'plex-3' })
|
|
.mockResolvedValueOnce({ authToken: 'token', plexId: 'plex-3', role: 'user' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid',
|
|
plexRatingKey: 'rk',
|
|
userRating: null,
|
|
},
|
|
]);
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'full');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: undefined,
|
|
rating: undefined,
|
|
},
|
|
]);
|
|
expect(plexMock.getServerAccessToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses plaintext token when decryption fails', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getPlexConfig.mockResolvedValue({
|
|
libraryId: 'plex-lib',
|
|
serverUrl: 'http://plex',
|
|
machineIdentifier: 'machine',
|
|
});
|
|
prismaMock.user.findUnique
|
|
.mockResolvedValueOnce({ plexId: 'plex-4' })
|
|
.mockResolvedValueOnce({ authToken: 'plain-token', plexId: 'plex-4', role: 'user' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Rated Book',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid-1',
|
|
plexRatingKey: 'rk-1',
|
|
userRating: null,
|
|
},
|
|
]);
|
|
encryptionMock.decrypt.mockImplementation(() => {
|
|
throw new Error('decrypt failed');
|
|
});
|
|
plexMock.getServerAccessToken.mockResolvedValue('server-token');
|
|
plexMock.getLibraryContent.mockResolvedValue([
|
|
{ guid: 'guid-1', ratingKey: 'rk-1', userRating: 7 },
|
|
]);
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'rated');
|
|
|
|
expect(result[0].rating).toBe(7);
|
|
expect(plexMock.getServerAccessToken).toHaveBeenCalledWith('machine', 'plain-token');
|
|
});
|
|
|
|
it('returns cached books when machine identifier is missing', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getPlexConfig.mockResolvedValue({
|
|
libraryId: 'plex-lib',
|
|
serverUrl: 'http://plex',
|
|
machineIdentifier: null,
|
|
});
|
|
prismaMock.user.findUnique
|
|
.mockResolvedValueOnce({ plexId: 'plex-5' })
|
|
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-5', role: 'user' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid',
|
|
plexRatingKey: 'rk',
|
|
userRating: null,
|
|
},
|
|
]);
|
|
encryptionMock.decrypt.mockReturnValue('user-token');
|
|
|
|
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserLibraryBooks('user-1', 'full');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
title: 'Book',
|
|
author: 'Author',
|
|
narrator: undefined,
|
|
rating: undefined,
|
|
},
|
|
]);
|
|
expect(plexMock.getServerAccessToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('builds recent swipe history from prioritized swipes', async () => {
|
|
const now = new Date();
|
|
const older = new Date(now.getTime() - 1000);
|
|
|
|
prismaMock.bookDateSwipe.findMany
|
|
.mockResolvedValueOnce([
|
|
{
|
|
bookTitle: 'Latest',
|
|
bookAuthor: 'Author',
|
|
action: 'right',
|
|
markedAsKnown: false,
|
|
createdAt: now,
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
bookTitle: 'Older',
|
|
bookAuthor: 'Author',
|
|
action: 'up',
|
|
markedAsKnown: false,
|
|
createdAt: older,
|
|
},
|
|
]);
|
|
|
|
const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserRecentSwipes('user-1', 2);
|
|
|
|
expect(result).toEqual([
|
|
{ title: 'Latest', author: 'Author', action: 'right', markedAsKnown: false },
|
|
{ title: 'Older', author: 'Author', action: 'up', markedAsKnown: false },
|
|
]);
|
|
expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('skips dismiss lookup when limit is filled by non-dismiss swipes', async () => {
|
|
prismaMock.bookDateSwipe.findMany.mockResolvedValueOnce([
|
|
{
|
|
bookTitle: 'Recent',
|
|
bookAuthor: 'Author',
|
|
action: 'right',
|
|
markedAsKnown: true,
|
|
createdAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers');
|
|
const result = await getUserRecentSwipes('user-1', 1);
|
|
|
|
expect(result).toEqual([
|
|
{ title: 'Recent', author: 'Author', action: 'right', markedAsKnown: true },
|
|
]);
|
|
expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('builds AI prompt with mapped swipe actions', async () => {
|
|
const now = new Date();
|
|
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
configMock.get.mockResolvedValue('abs-lib-1');
|
|
prismaMock.user.findUnique.mockResolvedValue({ plexId: 'local-1' });
|
|
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
|
{
|
|
title: 'Lib',
|
|
author: 'Author',
|
|
narrator: null,
|
|
plexGuid: 'guid',
|
|
plexRatingKey: 'rk',
|
|
userRating: '8',
|
|
},
|
|
]);
|
|
prismaMock.bookDateSwipe.findMany
|
|
.mockResolvedValueOnce([
|
|
{
|
|
bookTitle: 'Known',
|
|
bookAuthor: 'Author',
|
|
action: 'right',
|
|
markedAsKnown: true,
|
|
createdAt: now,
|
|
},
|
|
{
|
|
bookTitle: 'Requested',
|
|
bookAuthor: 'Author',
|
|
action: 'right',
|
|
markedAsKnown: false,
|
|
createdAt: new Date(now.getTime() - 1000),
|
|
},
|
|
{
|
|
bookTitle: 'Rejected',
|
|
bookAuthor: 'Author',
|
|
action: 'left',
|
|
markedAsKnown: false,
|
|
createdAt: new Date(now.getTime() - 2000),
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
bookTitle: 'Dismissed',
|
|
bookAuthor: 'Author',
|
|
action: 'up',
|
|
markedAsKnown: false,
|
|
createdAt: new Date(now.getTime() - 3000),
|
|
},
|
|
]);
|
|
|
|
const { buildAIPrompt } = await import('@/lib/bookdate/helpers');
|
|
const prompt = await buildAIPrompt('user-1', { libraryScope: 'full', customPrompt: 'prefs' });
|
|
const parsed = JSON.parse(prompt);
|
|
|
|
expect(parsed.user_context.library_books).toHaveLength(1);
|
|
expect(parsed.user_context.swipe_history).toEqual([
|
|
{ title: 'Known', author: 'Author', user_action: 'marked_as_liked' },
|
|
{ title: 'Requested', author: 'Author', user_action: 'requested' },
|
|
{ title: 'Rejected', author: 'Author', user_action: 'rejected' },
|
|
{ title: 'Dismissed', author: 'Author', user_action: 'dismissed' },
|
|
]);
|
|
});
|
|
|
|
it('returns cached Audnexus matches without fetching Audible', async () => {
|
|
prismaMock.audibleCache.findFirst.mockResolvedValue({
|
|
asin: 'ASIN1',
|
|
title: 'Title',
|
|
author: 'Author',
|
|
narrator: 'Narr',
|
|
rating: '4.5',
|
|
description: 'Desc',
|
|
coverArtUrl: 'cover',
|
|
});
|
|
|
|
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
|
const result = await matchToAudnexus('Title', 'Author');
|
|
|
|
expect(result?.asin).toBe('ASIN1');
|
|
expect(result?.rating).toBe(4.5);
|
|
expect(audibleState.ctor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns null when Audible search finds no results', async () => {
|
|
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
|
|
audibleState.instance.search.mockResolvedValue({ results: [] });
|
|
|
|
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
|
const result = await matchToAudnexus('Missing', 'Author');
|
|
|
|
expect(result).toBeNull();
|
|
expect(audibleState.instance.search).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns null when Audible details are unavailable', async () => {
|
|
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
|
|
audibleState.instance.search.mockResolvedValue({
|
|
results: [{ asin: 'ASIN2', title: 'Title', author: 'Author' }],
|
|
});
|
|
audibleState.instance.getAudiobookDetails.mockResolvedValue(null);
|
|
|
|
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
|
const result = await matchToAudnexus('Title', 'Author');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns Audnexus details for successful Audible matches', async () => {
|
|
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
|
|
audibleState.instance.search.mockResolvedValue({
|
|
results: [{ asin: 'ASIN3', title: 'Title', author: 'Author' }],
|
|
});
|
|
audibleState.instance.getAudiobookDetails.mockResolvedValue({
|
|
asin: 'ASIN3',
|
|
title: 'Title',
|
|
author: 'Author',
|
|
narrator: 'Narr',
|
|
rating: 4.2,
|
|
description: 'Desc',
|
|
coverArtUrl: 'cover',
|
|
});
|
|
|
|
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
|
const result = await matchToAudnexus('Title', 'Author');
|
|
|
|
expect(result).toEqual({
|
|
asin: 'ASIN3',
|
|
title: 'Title',
|
|
author: 'Author',
|
|
narrator: 'Narr',
|
|
rating: 4.2,
|
|
description: 'Desc',
|
|
coverUrl: 'cover',
|
|
});
|
|
});
|
|
|
|
it('checks library matches using the Plex matcher', async () => {
|
|
const { isInLibrary } = await import('@/lib/bookdate/helpers');
|
|
|
|
findPlexMatchMock.mockResolvedValueOnce({ title: 'Match' });
|
|
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(true);
|
|
|
|
findPlexMatchMock.mockResolvedValueOnce(null);
|
|
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(false);
|
|
});
|
|
|
|
it('checks existing requests and swipes', async () => {
|
|
const { isAlreadyRequested, isAlreadySwiped } = await import('@/lib/bookdate/helpers');
|
|
|
|
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-1' });
|
|
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
|
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({ id: 'swipe-1' });
|
|
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce(null);
|
|
|
|
await expect(isAlreadyRequested('user-1', 'ASIN1')).resolves.toBe(true);
|
|
await expect(isAlreadyRequested('user-1', 'ASIN1')).resolves.toBe(false);
|
|
await expect(isAlreadySwiped('user-1', 'Title', 'Author')).resolves.toBe(true);
|
|
await expect(isAlreadySwiped('user-1', 'Title', 'Author')).resolves.toBe(false);
|
|
});
|
|
|
|
it('throws on invalid AI provider', async () => {
|
|
const { callAI } = await import('@/lib/bookdate/helpers');
|
|
|
|
await expect(callAI('invalid', 'model', 'key', '{}')).rejects.toThrow('Invalid provider');
|
|
});
|
|
|
|
it('requires a base URL for custom providers', async () => {
|
|
const { callAI } = await import('@/lib/bookdate/helpers');
|
|
|
|
await expect(callAI('custom', 'model', 'key', '{}', null)).rejects.toThrow('Base URL is required');
|
|
});
|
|
|
|
it('calls OpenAI and parses JSON recommendations', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
choices: [{ message: { content: '{\"recommendations\":[]}' } }],
|
|
}),
|
|
text: vi.fn().mockResolvedValue('ok'),
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
encryptionMock.decrypt.mockReturnValue('api-key');
|
|
|
|
const { callAI } = await import('@/lib/bookdate/helpers');
|
|
const result = await callAI('openai', 'model', 'enc-key', '{}');
|
|
|
|
expect(result.recommendations).toEqual([]);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.openai.com/v1/chat/completions',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('calls Claude and strips markdown from JSON', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
content: [{ text: '```json\n{\"recommendations\":[]}\n```' }],
|
|
}),
|
|
text: vi.fn().mockResolvedValue('ok'),
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
encryptionMock.decrypt.mockReturnValue('api-key');
|
|
|
|
const { callAI } = await import('@/lib/bookdate/helpers');
|
|
const result = await callAI('claude', 'model', 'enc-key', '{}');
|
|
|
|
expect(result.recommendations).toEqual([]);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.anthropic.com/v1/messages',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('calls custom provider and parses direct JSON', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
choices: [{ message: { content: '{\"recommendations\":[]}' } }],
|
|
}),
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
encryptionMock.decrypt.mockReturnValue('api-key');
|
|
|
|
const { callAI } = await import('@/lib/bookdate/helpers');
|
|
const result = await callAI('custom', 'model', 'enc-key', '{}', 'http://custom/');
|
|
|
|
expect(result.recommendations).toEqual([]);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://custom/chat/completions',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
});
|
|
|
|
it('retries custom providers without structured output', async () => {
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 400,
|
|
text: vi.fn().mockResolvedValue('response_format unsupported'),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({
|
|
choices: [{ message: { content: '{\"recommendations\":[]}' } }],
|
|
}),
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
encryptionMock.decrypt.mockImplementation(() => {
|
|
throw new Error('decrypt failed');
|
|
});
|
|
|
|
const { callAI } = await import('@/lib/bookdate/helpers');
|
|
const result = await callAI('custom', 'model', 'enc-key', '{}', 'http://custom');
|
|
|
|
expect(result.recommendations).toEqual([]);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|