mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add per-user home sections & unified Audible cache
Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
This commit is contained in:
@@ -68,6 +68,12 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns popular audiobooks with cached cover URLs', async () => {
|
||||
// Mock AudibleCacheCategory query (popular route now queries category table)
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', rank: 1 },
|
||||
]);
|
||||
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1);
|
||||
// Mock AudibleCache metadata fetch
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
asin: 'ASIN',
|
||||
@@ -84,7 +90,6 @@ describe('Audiobooks browse routes', () => {
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
|
||||
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/popular/route');
|
||||
@@ -106,8 +111,9 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns new release audiobooks', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(0);
|
||||
// Mock AudibleCacheCategory query (new-releases route now queries category table)
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(0);
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
|
||||
@@ -118,6 +124,12 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('enriches new releases and uses cached cover URLs', async () => {
|
||||
// Mock AudibleCacheCategory query
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', rank: 1 },
|
||||
]);
|
||||
prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1);
|
||||
// Mock AudibleCache metadata fetch
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
asin: 'ASIN',
|
||||
@@ -134,7 +146,6 @@ describe('Audiobooks browse routes', () => {
|
||||
lastSyncedAt: new Date('2024-01-02'),
|
||||
},
|
||||
]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
|
||||
currentUserMock.mockReturnValue({ sub: 'user-1' });
|
||||
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]);
|
||||
|
||||
@@ -155,7 +166,7 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns 500 when new releases query fails', async () => {
|
||||
prismaMock.audibleCache.findMany.mockRejectedValueOnce(new Error('db down'));
|
||||
prismaMock.audibleCacheCategory.findMany.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
|
||||
@@ -209,6 +220,11 @@ describe('Audiobooks browse routes', () => {
|
||||
});
|
||||
|
||||
it('returns cached covers for login', async () => {
|
||||
// Mock AudibleCacheCategory query (covers route now queries category table)
|
||||
prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN' },
|
||||
]);
|
||||
// Mock AudibleCache metadata fetch
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null },
|
||||
]);
|
||||
@@ -221,5 +237,3 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Component: Home Sections API Route Tests
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: vi.fn((_req: any, handler: any) => {
|
||||
const mockReq = {
|
||||
user: { id: 'user-1', sub: 'user-1', role: 'user' },
|
||||
json: async () => (globalThis as any).__testBody || {},
|
||||
};
|
||||
return handler(mockReq);
|
||||
}),
|
||||
getCurrentUser: vi.fn(() => ({ sub: 'user-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: { create: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }) },
|
||||
}));
|
||||
|
||||
describe('GET /api/user/home-sections', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Re-apply default mock implementations after clearAllMocks
|
||||
prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));
|
||||
});
|
||||
|
||||
it('returns default sections for new user', async () => {
|
||||
// ensureDefaultSections check: no existing sections
|
||||
prismaMock.userHomeSection.findMany
|
||||
.mockResolvedValueOnce([]) // ensureDefaultSections
|
||||
.mockResolvedValueOnce([ // actual fetch after defaults created
|
||||
{ id: '1', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 0 },
|
||||
{ id: '2', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 1 },
|
||||
]);
|
||||
prismaMock.scheduledJob.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const { GET } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections');
|
||||
const response = await GET(request as any);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sections).toHaveLength(2);
|
||||
expect(data.sections[0].sectionType).toBe('popular');
|
||||
expect(data.sections[1].sectionType).toBe('new_releases');
|
||||
});
|
||||
|
||||
it('returns existing sections without creating defaults', async () => {
|
||||
prismaMock.userHomeSection.findMany
|
||||
.mockResolvedValueOnce([{ id: '1' }]) // has existing
|
||||
.mockResolvedValueOnce([
|
||||
{ id: '1', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 0 },
|
||||
]);
|
||||
prismaMock.scheduledJob.findFirst.mockResolvedValueOnce({
|
||||
nextRun: new Date('2026-03-05T00:00:00Z'),
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections');
|
||||
const response = await GET(request as any);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sections).toHaveLength(1);
|
||||
expect(data.sections[0].categoryName).toBe('Sci-Fi');
|
||||
expect(data.nextRefresh).toBe('2026-03-05T00:00:00.000Z');
|
||||
expect(prismaMock.userHomeSection.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/user/home-sections', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));
|
||||
});
|
||||
|
||||
it('saves new section configuration', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: [
|
||||
{ sectionType: 'new_releases', sortOrder: 0 },
|
||||
{ sectionType: 'popular', sortOrder: 1 },
|
||||
{ sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 },
|
||||
],
|
||||
};
|
||||
|
||||
prismaMock.userHomeSection.findMany.mockResolvedValueOnce([
|
||||
{ id: '1', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 0 },
|
||||
{ id: '2', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 1 },
|
||||
{ id: '3', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 },
|
||||
]);
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.sections).toHaveLength(3);
|
||||
expect(prismaMock.userHomeSection.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
});
|
||||
expect(prismaMock.userHomeSection.createMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects more than 10 sections', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: Array.from({ length: 11 }, (_, i) => ({
|
||||
sectionType: 'category',
|
||||
categoryId: `cat-${i}`,
|
||||
categoryName: `Cat ${i}`,
|
||||
sortOrder: i,
|
||||
})),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects duplicate sections', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: [
|
||||
{ sectionType: 'popular', sortOrder: 0 },
|
||||
{ sectionType: 'popular', sortOrder: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toContain('Duplicate');
|
||||
});
|
||||
|
||||
it('rejects category section without categoryId', async () => {
|
||||
(globalThis as any).__testBody = {
|
||||
sections: [{ sectionType: 'category', sortOrder: 0 }],
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/user/home-sections/route');
|
||||
const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' });
|
||||
const response = await PUT(request as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.message).toContain('categoryId');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Component: Home Page Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
@@ -12,15 +12,26 @@ import { resetMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const useAudiobooksMock = vi.hoisted(() => vi.fn());
|
||||
const useCategoryAudiobooksMock = vi.hoisted(() => vi.fn());
|
||||
const useHomeSectionsMock = vi.hoisted(() => vi.fn());
|
||||
const usePreferencesMock = vi.hoisted(() => ({
|
||||
cardSize: 5,
|
||||
setCardSize: vi.fn(),
|
||||
squareCovers: false,
|
||||
setSquareCovers: vi.fn(),
|
||||
hideAvailable: false,
|
||||
setHideAvailable: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
||||
useAudiobooks: useAudiobooksMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useHomeSections', () => ({
|
||||
useHomeSections: useHomeSectionsMock,
|
||||
useCategoryAudiobooks: useCategoryAudiobooksMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/contexts/PreferencesContext', () => ({
|
||||
usePreferences: () => usePreferencesMock,
|
||||
}));
|
||||
@@ -71,9 +82,25 @@ describe('HomePage', () => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
useAudiobooksMock.mockReset();
|
||||
useCategoryAudiobooksMock.mockReset();
|
||||
useHomeSectionsMock.mockReset();
|
||||
usePreferencesMock.cardSize = 5;
|
||||
usePreferencesMock.setCardSize.mockReset();
|
||||
usePreferencesMock.hideAvailable = false;
|
||||
vi.resetModules();
|
||||
|
||||
// Default: return popular + new_releases sections
|
||||
useHomeSectionsMock.mockReturnValue({
|
||||
sections: [
|
||||
{ id: '1', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 0 },
|
||||
{ id: '2', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 1 },
|
||||
],
|
||||
isLoading: false,
|
||||
nextRefresh: null,
|
||||
saveSections: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty state messaging for popular audiobooks', async () => {
|
||||
@@ -97,28 +124,39 @@ describe('HomePage', () => {
|
||||
const { default: HomePage } = await import('@/app/page');
|
||||
render(<HomePage />);
|
||||
|
||||
expect(screen.getByText('No popular audiobooks found')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
expect(screen.getByText('No audiobooks yet')).toBeInTheDocument();
|
||||
// Raw API message is intentionally not shown; friendly empty state is rendered instead
|
||||
expect(screen.queryByText('Nothing here')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('New Release')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates pagination when the sticky controls request a new page', async () => {
|
||||
useAudiobooksMock.mockImplementation((category: string, _limit: number, page: number) => {
|
||||
return {
|
||||
audiobooks: [{ asin: `${category}-${page}`, title: `${category}-${page}`, author: 'Author' }],
|
||||
isLoading: false,
|
||||
totalPages: 3,
|
||||
message: null,
|
||||
};
|
||||
it('renders customize button', async () => {
|
||||
useAudiobooksMock.mockReturnValue({
|
||||
audiobooks: [],
|
||||
isLoading: false,
|
||||
totalPages: 0,
|
||||
message: null,
|
||||
});
|
||||
|
||||
const { default: HomePage } = await import('@/app/page');
|
||||
render(<HomePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
||||
expect(screen.getByLabelText('Customize home page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined);
|
||||
it('renders empty state when no sections configured', async () => {
|
||||
useHomeSectionsMock.mockReturnValue({
|
||||
sections: [],
|
||||
isLoading: false,
|
||||
nextRefresh: null,
|
||||
saveSections: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { default: HomePage } = await import('@/app/page');
|
||||
render(<HomePage />);
|
||||
|
||||
expect(screen.getByText(/No sections configured/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ type PrismaModelMock = {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
@@ -23,6 +24,7 @@ const createModelMock = (): PrismaModelMock => ({
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(() => Promise.resolve({})),
|
||||
createMany: vi.fn(() => Promise.resolve({ count: 0 })),
|
||||
update: vi.fn(() => Promise.resolve({})),
|
||||
updateMany: vi.fn(() => Promise.resolve({})),
|
||||
upsert: vi.fn(() => Promise.resolve({})),
|
||||
@@ -52,6 +54,9 @@ export const createPrismaMock = () => ({
|
||||
workAsin: createModelMock(),
|
||||
watchedSeries: createModelMock(),
|
||||
watchedAuthor: createModelMock(),
|
||||
userHomeSection: createModelMock(),
|
||||
audibleCacheCategory: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const prismaMock = createPrismaMock();
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getPopularAudiobooks: vi.fn(),
|
||||
getNewReleases: vi.fn(),
|
||||
getCategoryBooks: vi.fn(),
|
||||
}));
|
||||
const thumbnailCacheMock = vi.hoisted(() => ({
|
||||
cacheThumbnail: vi.fn(),
|
||||
@@ -45,7 +46,7 @@ describe('processAudibleRefresh', () => {
|
||||
global.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
it('refreshes popular and new releases, caching thumbnails', async () => {
|
||||
it('refreshes popular and new releases via AudibleCacheCategory', async () => {
|
||||
const popular = [
|
||||
{
|
||||
asin: 'ASIN-1',
|
||||
@@ -91,8 +92,12 @@ describe('processAudibleRefresh', () => {
|
||||
audibleServiceMock.getNewReleases.mockResolvedValue(newReleases);
|
||||
thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/path.jpg');
|
||||
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(2);
|
||||
prismaMock.audibleCache.updateMany.mockResolvedValue({ count: 1 });
|
||||
prismaMock.audibleCache.upsert.mockResolvedValue({});
|
||||
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
|
||||
// No user-configured categories
|
||||
prismaMock.userHomeSection.findMany.mockResolvedValue([]);
|
||||
|
||||
prismaMock.audibleCache.findMany.mockResolvedValue([
|
||||
{ asin: 'ASIN-1' },
|
||||
{ asin: 'ASIN-2' },
|
||||
@@ -105,8 +110,32 @@ describe('processAudibleRefresh', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.popularSaved).toBe(2);
|
||||
expect(result.newReleasesSaved).toBe(1);
|
||||
expect(prismaMock.audibleCache.updateMany).toHaveBeenCalled();
|
||||
expect(result.categoriesSynced).toBe(0);
|
||||
|
||||
// Should wipe old entries for __popular__ and __new_releases__
|
||||
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
|
||||
where: { categoryId: '__popular__' },
|
||||
});
|
||||
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
|
||||
where: { categoryId: '__new_releases__' },
|
||||
});
|
||||
|
||||
// 3 metadata upserts (2 popular + 1 new release)
|
||||
expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3);
|
||||
|
||||
// 3 category entries created (2 popular + 1 new release)
|
||||
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledTimes(3);
|
||||
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ asin: 'ASIN-1', categoryId: '__popular__', rank: 1 }),
|
||||
});
|
||||
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ asin: 'ASIN-2', categoryId: '__popular__', rank: 2 }),
|
||||
});
|
||||
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ asin: 'ASIN-3', categoryId: '__new_releases__', rank: 1 }),
|
||||
});
|
||||
|
||||
// Thumbnail caching still works
|
||||
expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-1', 'http://image/1');
|
||||
expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-3', 'http://image/3');
|
||||
expect(thumbnailCacheMock.cleanupUnusedThumbnails).toHaveBeenCalled();
|
||||
@@ -115,8 +144,56 @@ describe('processAudibleRefresh', () => {
|
||||
expect(Array.from(activeSet).sort()).toEqual(['ASIN-1', 'ASIN-2', 'ASIN-3']);
|
||||
});
|
||||
|
||||
it('scrapes user-configured categories after popular/new-releases', async () => {
|
||||
audibleServiceMock.getPopularAudiobooks.mockResolvedValue([]);
|
||||
audibleServiceMock.getNewReleases.mockResolvedValue([]);
|
||||
thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/cat.jpg');
|
||||
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(0);
|
||||
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
|
||||
|
||||
// User has one category section
|
||||
prismaMock.userHomeSection.findMany.mockResolvedValue([
|
||||
{ categoryId: 'node-42' },
|
||||
]);
|
||||
|
||||
// getCategoryBooks returns 2 books
|
||||
audibleServiceMock.getCategoryBooks.mockResolvedValue([
|
||||
{ asin: 'CAT-1', title: 'Cat Book 1', author: 'Author', coverArtUrl: 'http://img/c1' },
|
||||
{ asin: 'CAT-2', title: 'Cat Book 2', author: 'Author', coverArtUrl: null },
|
||||
]);
|
||||
|
||||
prismaMock.audibleCache.upsert.mockResolvedValue({});
|
||||
prismaMock.audibleCache.findMany.mockResolvedValue([]);
|
||||
|
||||
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
||||
const result = await processAudibleRefresh({ jobId: 'job-cat' });
|
||||
|
||||
expect(result.categoriesSynced).toBe(1);
|
||||
expect(audibleServiceMock.getCategoryBooks).toHaveBeenCalledWith('node-42', 200);
|
||||
|
||||
// Should wipe entries for __popular__, __new_releases__, and node-42
|
||||
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
|
||||
where: { categoryId: '__popular__' },
|
||||
});
|
||||
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
|
||||
where: { categoryId: '__new_releases__' },
|
||||
});
|
||||
expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({
|
||||
where: { categoryId: 'node-42' },
|
||||
});
|
||||
|
||||
// 2 category book creates (for node-42) — popular/new-releases had 0 books
|
||||
expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledTimes(2);
|
||||
expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rethrows fatal errors', async () => {
|
||||
prismaMock.audibleCache.updateMany.mockRejectedValue(new Error('DB down'));
|
||||
// Mock audible service to return data so we reach the DB calls
|
||||
audibleServiceMock.getPopularAudiobooks.mockResolvedValue([]);
|
||||
audibleServiceMock.getNewReleases.mockResolvedValue([]);
|
||||
// First DB call is now audibleCacheCategory.deleteMany (for __popular__)
|
||||
prismaMock.audibleCacheCategory.deleteMany.mockRejectedValue(new Error('DB down'));
|
||||
|
||||
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
||||
await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');
|
||||
|
||||
Reference in New Issue
Block a user