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:
@@ -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