Files
ReadMeABook/tests/bookdate/helpers.test.ts
T
kikootwo 94dbaf073b Add backend unit test framework and modularize settings UI
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.
2026-01-28 11:41:59 -05:00

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);
});
});