mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix: prevent stale read status when navigating between similar books
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: mocks.api,
|
||||
}))
|
||||
|
||||
import { useBookDetail } from '../useBookDetail'
|
||||
|
||||
function makeResponse(data: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => data,
|
||||
} as Response
|
||||
}
|
||||
|
||||
function deferredResponse() {
|
||||
let resolve!: (response: Response) => void
|
||||
let reject!: (error?: unknown) => void
|
||||
const promise = new Promise<Response>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('useBookDetail', () => {
|
||||
beforeEach(() => {
|
||||
mocks.api.mockReset()
|
||||
})
|
||||
|
||||
it('loads a book detail successfully', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse({ id: 20, title: 'Dune', readStatus: { status: 'reading' } }))
|
||||
const detailState = useBookDetail()
|
||||
|
||||
await detailState.fetch(20)
|
||||
|
||||
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/20')
|
||||
expect(detailState.detail.value).toEqual({ id: 20, title: 'Dune', readStatus: { status: 'reading' } })
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
expect(detailState.error.value).toBeNull()
|
||||
expect(detailState.notFound.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale detail immediately while a new request is loading', async () => {
|
||||
const pending = deferredResponse()
|
||||
mocks.api.mockReturnValue(pending.promise)
|
||||
const detailState = useBookDetail()
|
||||
detailState.detail.value = { id: 19 } as never
|
||||
|
||||
const fetchPromise = detailState.fetch(20)
|
||||
|
||||
expect(detailState.detail.value).toBeNull()
|
||||
expect(detailState.loading.value).toBe(true)
|
||||
expect(detailState.notFound.value).toBe(false)
|
||||
expect(detailState.error.value).toBeNull()
|
||||
|
||||
pending.resolve(makeResponse({ id: 20, title: 'Hyperion', readStatus: null }))
|
||||
await fetchPromise
|
||||
|
||||
expect(detailState.detail.value).toEqual({ id: 20, title: 'Hyperion', readStatus: null })
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the current detail visible while reloading the same book', async () => {
|
||||
const pending = deferredResponse()
|
||||
mocks.api.mockReturnValue(pending.promise)
|
||||
const detailState = useBookDetail()
|
||||
detailState.detail.value = { id: 20, title: 'Current Book', readStatus: { status: 'reading' } } as never
|
||||
|
||||
const fetchPromise = detailState.fetch(20)
|
||||
|
||||
expect(detailState.detail.value).toEqual({ id: 20, title: 'Current Book', readStatus: { status: 'reading' } })
|
||||
expect(detailState.loading.value).toBe(true)
|
||||
|
||||
pending.resolve(makeResponse({ id: 20, title: 'Current Book', readStatus: { status: 'finished' } }))
|
||||
await fetchPromise
|
||||
|
||||
expect(detailState.detail.value).toEqual({ id: 20, title: 'Current Book', readStatus: { status: 'finished' } })
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('marks notFound for a 404 response', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse({ message: 'missing' }, 404))
|
||||
const detailState = useBookDetail()
|
||||
detailState.detail.value = { id: 19 } as never
|
||||
|
||||
await detailState.fetch(999)
|
||||
|
||||
expect(detailState.detail.value).toBeNull()
|
||||
expect(detailState.notFound.value).toBe(true)
|
||||
expect(detailState.error.value).toBeNull()
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces HTTP failures', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse({ message: 'boom' }, 500))
|
||||
const detailState = useBookDetail()
|
||||
|
||||
await detailState.fetch(20)
|
||||
|
||||
expect(detailState.detail.value).toBeNull()
|
||||
expect(detailState.notFound.value).toBe(false)
|
||||
expect(detailState.error.value).toBe('HTTP 500')
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces thrown network errors', async () => {
|
||||
mocks.api.mockRejectedValue(new Error('network down'))
|
||||
const detailState = useBookDetail()
|
||||
|
||||
await detailState.fetch(20)
|
||||
|
||||
expect(detailState.detail.value).toBeNull()
|
||||
expect(detailState.error.value).toBe('network down')
|
||||
expect(detailState.notFound.value).toBe(false)
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores stale earlier responses after a newer fetch starts', async () => {
|
||||
const first = deferredResponse()
|
||||
const second = deferredResponse()
|
||||
mocks.api.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
const detailState = useBookDetail()
|
||||
|
||||
const firstFetch = detailState.fetch(20)
|
||||
const secondFetch = detailState.fetch(21)
|
||||
|
||||
second.resolve(makeResponse({ id: 21, title: 'New Book', readStatus: null }))
|
||||
await secondFetch
|
||||
|
||||
first.resolve(makeResponse({ id: 20, title: 'Old Book', readStatus: { status: 'reading' } }))
|
||||
await firstFetch
|
||||
|
||||
expect(detailState.detail.value).toEqual({ id: 21, title: 'New Book', readStatus: null })
|
||||
expect(detailState.error.value).toBeNull()
|
||||
expect(detailState.notFound.value).toBe(false)
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores stale earlier failures after a newer fetch succeeds', async () => {
|
||||
const first = deferredResponse()
|
||||
const second = deferredResponse()
|
||||
mocks.api.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
const detailState = useBookDetail()
|
||||
|
||||
const firstFetch = detailState.fetch(20)
|
||||
const secondFetch = detailState.fetch(21)
|
||||
|
||||
second.resolve(makeResponse({ id: 21, title: 'New Book', readStatus: null }))
|
||||
await secondFetch
|
||||
|
||||
first.reject(new Error('stale failure'))
|
||||
await firstFetch
|
||||
|
||||
expect(detailState.detail.value).toEqual({ id: 21, title: 'New Book', readStatus: null })
|
||||
expect(detailState.error.value).toBeNull()
|
||||
expect(detailState.loading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -7,22 +7,36 @@ export function useBookDetail() {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const notFound = ref(false)
|
||||
let activeRequestId = 0
|
||||
|
||||
async function fetch(bookId: number) {
|
||||
const requestId = ++activeRequestId
|
||||
const shouldClearDetail = detail.value?.id !== bookId
|
||||
loading.value = true
|
||||
error.value = null
|
||||
notFound.value = false
|
||||
if (shouldClearDetail) detail.value = null
|
||||
|
||||
try {
|
||||
const res = await api(`/api/v1/books/${bookId}`)
|
||||
if (res.status === 404) {
|
||||
if (requestId === activeRequestId && res.status === 404) {
|
||||
notFound.value = true
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
detail.value = await res.json()
|
||||
if (requestId === activeRequestId && res.status !== 404) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const nextDetail = (await res.json()) as BookDetail
|
||||
if (requestId === activeRequestId) {
|
||||
detail.value = nextDetail
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load book'
|
||||
} finally {
|
||||
if (requestId === activeRequestId) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load book'
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId === activeRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ const mockState = vi.hoisted(() => ({
|
||||
route: null as unknown as { params: { bookId: string }; query: Record<string, unknown> },
|
||||
detail: null as unknown as { value: BookDetail | null },
|
||||
loading: null as unknown as { value: boolean },
|
||||
fetch: vi.fn<() => void>(),
|
||||
notFound: null as unknown as { value: boolean },
|
||||
fetch: vi.fn<(bookId: number) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
@@ -23,6 +24,7 @@ vi.mock('@/features/book/composables/useBookDetail', () => ({
|
||||
useBookDetail: () => ({
|
||||
detail: mockState.detail,
|
||||
loading: mockState.loading,
|
||||
notFound: mockState.notFound,
|
||||
fetch: mockState.fetch,
|
||||
}),
|
||||
}))
|
||||
@@ -131,6 +133,7 @@ describe('BookDetailView', () => {
|
||||
})
|
||||
mockState.detail = ref(makeBook())
|
||||
mockState.loading = ref(false)
|
||||
mockState.notFound = ref(false)
|
||||
mockState.fetch.mockReset()
|
||||
})
|
||||
|
||||
@@ -160,4 +163,44 @@ describe('BookDetailView', () => {
|
||||
await nextTick()
|
||||
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('4')
|
||||
})
|
||||
|
||||
it('clears the old detail view while a new route fetch is in flight', async () => {
|
||||
mockState.fetch.mockImplementation((bookId: number) => {
|
||||
if (bookId !== 218) return
|
||||
|
||||
mockState.detail.value = null
|
||||
mockState.loading.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
mockState.detail.value = makeBook({ id: 218, rating: null, readStatus: null, title: 'Next Book' })
|
||||
mockState.loading.value = false
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const wrapper = mount(BookDetailView, {
|
||||
global: {
|
||||
stubs: {
|
||||
BookDetailLayout: { template: '<div><slot /></div>' },
|
||||
DetailsTab: DetailsTabStub,
|
||||
EditMetadataTab: EditMetadataTabStub,
|
||||
FilesTab: { template: '<div />' },
|
||||
EntityNotFound: { template: '<div data-test="not-found" />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('2')
|
||||
|
||||
mockState.route.params.bookId = '218'
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-test="details-rating"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.animate-shimmer').exists()).toBe(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(218)
|
||||
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1951,6 +1951,26 @@ describe('BookService', () => {
|
||||
expect(onProgress).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bulkRefreshMetadata returns zero counts without checking access when no books are requested', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
|
||||
await expect(service.bulkRefreshMetadata([], makeUser())).resolves.toEqual({ processed: 0, failed: 0 });
|
||||
|
||||
expect(bookRepo.findLibraryIdsByBookIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bulkRefreshMetadata logs and rethrows access failures before iteration starts', async () => {
|
||||
const { service } = makeService();
|
||||
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
|
||||
const user = makeUser();
|
||||
const accessError = new ForbiddenException('denied');
|
||||
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockRejectedValue(accessError);
|
||||
|
||||
await expect(service.bulkRefreshMetadata([1], user)).rejects.toThrow(accessError);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bulkReExtractCover aborts early when cancellation is requested', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
const user = makeUser();
|
||||
@@ -1961,6 +1981,26 @@ describe('BookService', () => {
|
||||
|
||||
expect(result).toEqual({ processed: 0, updated: 0 });
|
||||
});
|
||||
|
||||
it('bulkReExtractCover returns zero counts without checking access when no books are requested', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
|
||||
await expect(service.bulkReExtractCover([], makeUser())).resolves.toEqual({ processed: 0, updated: 0 });
|
||||
|
||||
expect(bookRepo.findLibraryIdsByBookIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bulkReExtractCover logs and rethrows access failures before loading files', async () => {
|
||||
const { service } = makeService();
|
||||
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
|
||||
const user = makeUser();
|
||||
const accessError = new ForbiddenException('denied');
|
||||
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockRejectedValue(accessError);
|
||||
|
||||
await expect(service.bulkReExtractCover([1], user)).rejects.toThrow(accessError);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk metadata actions', () => {
|
||||
@@ -1994,6 +2034,20 @@ describe('BookService', () => {
|
||||
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('bulkSetRating logs score recalculation failures without interrupting the update', async () => {
|
||||
const { service, bookRepo, scoreService } = makeService();
|
||||
const user = makeUser({ id: 42 });
|
||||
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
|
||||
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
|
||||
scoreService.calculateAndSave.mockRejectedValue(new Error('score exploded'));
|
||||
|
||||
await service.bulkSetRating([3], 4, user);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3], 4, 42);
|
||||
expect(warnSpy).toHaveBeenCalledWith('Score calculation failed for book 3: score exploded');
|
||||
});
|
||||
|
||||
it('bulkSetMetadata updates a single metadata field and queues follow-up work', async () => {
|
||||
const { service, bookRepo, fileWriteService, scoreService } = makeService();
|
||||
const user = makeUser({ id: 11 });
|
||||
@@ -2173,7 +2227,13 @@ describe('BookService', () => {
|
||||
],
|
||||
narratorRows: [{ id: 4, name: 'Narrator Name', sortName: null, displayOrder: 0 }],
|
||||
});
|
||||
userBookStatusService.findOne.mockResolvedValue({ status: 'reading' });
|
||||
userBookStatusService.findOne.mockResolvedValue({
|
||||
status: 'reading',
|
||||
source: 'manual',
|
||||
startedAt: '2026-01-03T00:00:00.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-01-04T00:00:00.000Z',
|
||||
});
|
||||
comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] });
|
||||
bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]);
|
||||
bookRepo.findRatingByBookAndUser.mockResolvedValue(5);
|
||||
@@ -2185,10 +2245,103 @@ describe('BookService', () => {
|
||||
{ title: '01-intro', startMs: 0 },
|
||||
{ title: '02-main', startMs: 30_000 },
|
||||
]);
|
||||
expect(result.readStatus).toEqual({
|
||||
status: 'reading',
|
||||
source: 'manual',
|
||||
startedAt: '2026-01-03T00:00:00.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-01-04T00:00:00.000Z',
|
||||
});
|
||||
expect(result.files[0]?.role).toBe('primary');
|
||||
expect(result.collections).toEqual([{ id: 3, name: 'Favorites' }]);
|
||||
expect(result.lockedFields).toEqual(['title']);
|
||||
expect(result.comicMetadata).toEqual(expect.objectContaining({ issueNumber: '1', teams: ['House Atreides'] }));
|
||||
expect(result.rating).toBe(5);
|
||||
expect(result.providerIds.google).toBe('g1');
|
||||
});
|
||||
|
||||
it('preserves null personal detail fields when no user-specific state exists', async () => {
|
||||
const { service, bookRepo, userBookStatusService, comicMetadataService } = makeService();
|
||||
const user = makeUser();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
bookRepo.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: {
|
||||
id: 12,
|
||||
libraryId: 3,
|
||||
primaryFileId: 400,
|
||||
status: 'present',
|
||||
folderPath: '/books/foundation',
|
||||
addedAt: new Date('2026-02-01T00:00:00.000Z'),
|
||||
},
|
||||
libraries: { name: 'Main', formatPriority: null },
|
||||
book_metadata: {
|
||||
title: 'Foundation',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
rating: null,
|
||||
coverSource: null,
|
||||
lockedFields: null,
|
||||
googleBooksId: null,
|
||||
goodreadsId: null,
|
||||
amazonId: null,
|
||||
hardcoverId: null,
|
||||
openLibraryId: null,
|
||||
itunesId: null,
|
||||
audibleId: null,
|
||||
comicvineId: null,
|
||||
chapters: null,
|
||||
durationSeconds: null,
|
||||
abridged: null,
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
},
|
||||
},
|
||||
authorRows: [{ id: 2, name: 'Isaac Asimov', sortName: 'Asimov, Isaac' }],
|
||||
genreRows: [{ name: 'Sci-Fi' }],
|
||||
tagRows: [],
|
||||
fileRows: [
|
||||
{
|
||||
id: 400,
|
||||
format: 'epub',
|
||||
role: 'content',
|
||||
sizeBytes: 42,
|
||||
absolutePath: '/books/foundation.epub',
|
||||
createdAt: new Date('2026-02-01T00:00:00.000Z'),
|
||||
durationSeconds: null,
|
||||
},
|
||||
],
|
||||
narratorRows: [],
|
||||
});
|
||||
userBookStatusService.findOne.mockResolvedValue(null);
|
||||
comicMetadataService.findByBookId.mockResolvedValue(null);
|
||||
bookRepo.findCollectionsByBookId.mockResolvedValue([]);
|
||||
bookRepo.findRatingByBookAndUser.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getDetail(12, user);
|
||||
|
||||
expect(result.readStatus).toBeNull();
|
||||
expect(result.rating).toBeNull();
|
||||
expect(result.audioMetadata).toBeNull();
|
||||
expect(result.collections).toEqual([]);
|
||||
expect(result.comicMetadata).toBeNull();
|
||||
expect(result.lockedFields).toEqual([]);
|
||||
expect(result.formatPriority).toEqual([]);
|
||||
expect(result.files).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 400,
|
||||
role: 'primary',
|
||||
filename: 'foundation.epub',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('getMetadataFromFile handles missing or unsupported primary files', async () => {
|
||||
@@ -2201,6 +2354,14 @@ describe('BookService', () => {
|
||||
await expect(service.getMetadataFromFile(1, user)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('getMetadataFromFile returns an empty object for unsupported but known file formats', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/archive.azw3', format: 'azw3' });
|
||||
|
||||
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('maps epub metadata to update payload shape', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
@@ -2348,6 +2509,21 @@ describe('BookService', () => {
|
||||
});
|
||||
|
||||
describe('export edge cases', () => {
|
||||
it('resolves missing sizeBytes from disk stat for projected exports', async () => {
|
||||
const { service, bookRepo, appSettings } = makeService();
|
||||
const user = makeUser();
|
||||
appSettings.getDownloadPattern.mockResolvedValue('{title}');
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
|
||||
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/dune.epub', format: 'epub', sizeBytes: null }]);
|
||||
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Dune' })]);
|
||||
mockStat.mockResolvedValue({ size: 321 } as never);
|
||||
|
||||
const plan = await service.getExportFiles([1], user, 'primary');
|
||||
|
||||
expect(plan.projectedBytes).toBe(321);
|
||||
expect(plan.files).toEqual([{ absolutePath: '/books/dune.epub', zipPath: 'Dune.epub', sizeBytes: 321 }]);
|
||||
});
|
||||
|
||||
it('resolves missing sizeBytes from disk stat and throws when file is missing', async () => {
|
||||
const { service, bookRepo, appSettings } = makeService();
|
||||
const user = makeUser();
|
||||
@@ -2360,6 +2536,18 @@ describe('BookService', () => {
|
||||
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rethrows unexpected stat failures while resolving export file sizes', async () => {
|
||||
const { service, bookRepo, appSettings } = makeService();
|
||||
const user = makeUser();
|
||||
appSettings.getDownloadPattern.mockResolvedValue('{title}');
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
|
||||
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/secret.epub', format: 'epub', sizeBytes: null }]);
|
||||
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Secret' })]);
|
||||
mockStat.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
|
||||
|
||||
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow('permission denied');
|
||||
});
|
||||
|
||||
it('sorts equal-order export files by absolute path as final tie-breaker', async () => {
|
||||
const { service, bookRepo, appSettings } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
@@ -56,6 +56,7 @@ import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
|
||||
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
import { buildBookDetailSupplementalFields } from './utils/build-book-detail-supplemental-fields';
|
||||
|
||||
const METADATA_UPDATE_FAILPOINTS = [
|
||||
'afterScalarUpdate',
|
||||
@@ -1860,6 +1861,18 @@ export class BookService {
|
||||
const meta = book.book_metadata;
|
||||
const hasAudioFiles = fileRows.some((f) => f.format && isAudioFormat(f.format));
|
||||
const resolvedChapters = this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, fileRows);
|
||||
const supplementalFields = buildBookDetailSupplementalFields({
|
||||
readStatus,
|
||||
hasAudioFiles,
|
||||
narratorRows,
|
||||
audioMeta: {
|
||||
durationSeconds: meta?.durationSeconds,
|
||||
abridged: meta?.abridged,
|
||||
},
|
||||
chapters: resolvedChapters,
|
||||
comicMeta,
|
||||
collections: collectionRows,
|
||||
});
|
||||
|
||||
return {
|
||||
id: book.books.id,
|
||||
@@ -1907,32 +1920,8 @@ export class BookService {
|
||||
})),
|
||||
lastWrittenAt: meta?.lastWrittenAt ?? null,
|
||||
metadataScore: meta?.metadataScore ?? null,
|
||||
readStatus,
|
||||
audioMetadata: hasAudioFiles
|
||||
? {
|
||||
narrators: narratorRows.map((n, i) => ({ id: n.id, name: n.name, sortName: n.sortName, displayOrder: i })),
|
||||
durationSeconds: meta?.durationSeconds ?? null,
|
||||
abridged: meta?.abridged ?? false,
|
||||
chapters: resolvedChapters,
|
||||
}
|
||||
: null,
|
||||
formatPriority: (book.libraries?.formatPriority as string[] | null) ?? [],
|
||||
comicMetadata: comicMeta
|
||||
? {
|
||||
issueNumber: comicMeta.issueNumber ?? undefined,
|
||||
volumeName: comicMeta.volumeName ?? undefined,
|
||||
pencillers: comicMeta.pencillers ?? undefined,
|
||||
inkers: comicMeta.inkers ?? undefined,
|
||||
colorists: comicMeta.colorists ?? undefined,
|
||||
letterers: comicMeta.letterers ?? undefined,
|
||||
coverArtists: comicMeta.coverArtists ?? undefined,
|
||||
characters: comicMeta.characters ?? undefined,
|
||||
teams: comicMeta.teams ?? undefined,
|
||||
locations: comicMeta.locations ?? undefined,
|
||||
storyArcs: comicMeta.storyArcs ?? undefined,
|
||||
}
|
||||
: null,
|
||||
collections: collectionRows,
|
||||
...supplementalFields,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildBookDetailSupplementalFields } from './build-book-detail-supplemental-fields';
|
||||
|
||||
describe('buildBookDetailSupplementalFields', () => {
|
||||
it('returns null supplemental state when no personal or media state exists', () => {
|
||||
const result = buildBookDetailSupplementalFields({
|
||||
readStatus: null,
|
||||
hasAudioFiles: false,
|
||||
narratorRows: [],
|
||||
audioMeta: { durationSeconds: undefined, abridged: undefined },
|
||||
chapters: null,
|
||||
comicMeta: null,
|
||||
collections: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
readStatus: null,
|
||||
audioMetadata: null,
|
||||
comicMetadata: null,
|
||||
collections: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps audio detail with stable narrator ordering and default abridged=false', () => {
|
||||
const result = buildBookDetailSupplementalFields({
|
||||
readStatus: {
|
||||
status: 'reading',
|
||||
source: 'manual',
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
hasAudioFiles: true,
|
||||
narratorRows: [
|
||||
{ id: 2, name: 'Narrator Two', sortName: 'Two, Narrator' },
|
||||
{ id: 1, name: 'Narrator One', sortName: null },
|
||||
],
|
||||
audioMeta: { durationSeconds: 5400, abridged: null },
|
||||
chapters: [{ title: 'Chapter 1', startMs: 0 }],
|
||||
comicMeta: null,
|
||||
collections: [{ id: 9, name: 'Favorites' }],
|
||||
});
|
||||
|
||||
expect(result.readStatus).toEqual({
|
||||
status: 'reading',
|
||||
source: 'manual',
|
||||
startedAt: '2026-01-01T00:00:00.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
});
|
||||
expect(result.audioMetadata).toEqual({
|
||||
narrators: [
|
||||
{ id: 2, name: 'Narrator Two', sortName: 'Two, Narrator', displayOrder: 0 },
|
||||
{ id: 1, name: 'Narrator One', sortName: null, displayOrder: 1 },
|
||||
],
|
||||
durationSeconds: 5400,
|
||||
abridged: false,
|
||||
chapters: [{ title: 'Chapter 1', startMs: 0 }],
|
||||
});
|
||||
expect(result.collections).toEqual([{ id: 9, name: 'Favorites' }]);
|
||||
});
|
||||
|
||||
it('maps comic metadata and drops null fields to undefined', () => {
|
||||
const result = buildBookDetailSupplementalFields({
|
||||
readStatus: null,
|
||||
hasAudioFiles: false,
|
||||
narratorRows: [],
|
||||
audioMeta: { durationSeconds: null, abridged: false },
|
||||
chapters: null,
|
||||
comicMeta: {
|
||||
issueNumber: '7',
|
||||
volumeName: null,
|
||||
pencillers: ['Penciller'],
|
||||
inkers: null,
|
||||
colorists: ['Colorist'],
|
||||
letterers: null,
|
||||
coverArtists: ['Cover Artist'],
|
||||
characters: ['Hero'],
|
||||
teams: ['Team'],
|
||||
locations: null,
|
||||
storyArcs: ['Arc'],
|
||||
},
|
||||
collections: [],
|
||||
});
|
||||
|
||||
expect(result.comicMetadata).toEqual({
|
||||
issueNumber: '7',
|
||||
volumeName: undefined,
|
||||
pencillers: ['Penciller'],
|
||||
inkers: undefined,
|
||||
colorists: ['Colorist'],
|
||||
letterers: undefined,
|
||||
coverArtists: ['Cover Artist'],
|
||||
characters: ['Hero'],
|
||||
teams: ['Team'],
|
||||
locations: undefined,
|
||||
storyArcs: ['Arc'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AudiobookChapter, ComicMetadataFields, NarratorRef, UserBookStatus } from '@bookorbit/types';
|
||||
import type { BookDetailDto } from '../dto/book-detail.dto';
|
||||
|
||||
type NarratorRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
sortName: string | null;
|
||||
};
|
||||
|
||||
type AudioMetaSource = {
|
||||
durationSeconds: number | null | undefined;
|
||||
abridged: boolean | null | undefined;
|
||||
};
|
||||
|
||||
type ComicMetaSource = {
|
||||
issueNumber?: string | null;
|
||||
volumeName?: string | null;
|
||||
pencillers?: string[] | null;
|
||||
inkers?: string[] | null;
|
||||
colorists?: string[] | null;
|
||||
letterers?: string[] | null;
|
||||
coverArtists?: string[] | null;
|
||||
characters?: string[] | null;
|
||||
teams?: string[] | null;
|
||||
locations?: string[] | null;
|
||||
storyArcs?: string[] | null;
|
||||
} | null;
|
||||
|
||||
type CollectionRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type SupplementalFieldInput = {
|
||||
readStatus: UserBookStatus | null;
|
||||
hasAudioFiles: boolean;
|
||||
narratorRows: NarratorRow[];
|
||||
audioMeta: AudioMetaSource;
|
||||
chapters: AudiobookChapter[] | null;
|
||||
comicMeta: ComicMetaSource;
|
||||
collections: CollectionRow[];
|
||||
};
|
||||
|
||||
function buildAudioMetadata(
|
||||
hasAudioFiles: boolean,
|
||||
narratorRows: NarratorRow[],
|
||||
audioMeta: AudioMetaSource,
|
||||
chapters: AudiobookChapter[] | null,
|
||||
): BookDetailDto['audioMetadata'] {
|
||||
if (!hasAudioFiles) return null;
|
||||
|
||||
return {
|
||||
narrators: narratorRows.map<NarratorRef>((narrator, index) => ({
|
||||
id: narrator.id,
|
||||
name: narrator.name,
|
||||
sortName: narrator.sortName,
|
||||
displayOrder: index,
|
||||
})),
|
||||
durationSeconds: audioMeta.durationSeconds ?? null,
|
||||
abridged: audioMeta.abridged ?? false,
|
||||
chapters,
|
||||
};
|
||||
}
|
||||
|
||||
function buildComicMetadata(comicMeta: ComicMetaSource): ComicMetadataFields | null {
|
||||
if (!comicMeta) return null;
|
||||
|
||||
return {
|
||||
issueNumber: comicMeta.issueNumber ?? undefined,
|
||||
volumeName: comicMeta.volumeName ?? undefined,
|
||||
pencillers: comicMeta.pencillers ?? undefined,
|
||||
inkers: comicMeta.inkers ?? undefined,
|
||||
colorists: comicMeta.colorists ?? undefined,
|
||||
letterers: comicMeta.letterers ?? undefined,
|
||||
coverArtists: comicMeta.coverArtists ?? undefined,
|
||||
characters: comicMeta.characters ?? undefined,
|
||||
teams: comicMeta.teams ?? undefined,
|
||||
locations: comicMeta.locations ?? undefined,
|
||||
storyArcs: comicMeta.storyArcs ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBookDetailSupplementalFields({
|
||||
readStatus,
|
||||
hasAudioFiles,
|
||||
narratorRows,
|
||||
audioMeta,
|
||||
chapters,
|
||||
comicMeta,
|
||||
collections,
|
||||
}: SupplementalFieldInput): Pick<BookDetailDto, 'readStatus' | 'audioMetadata' | 'comicMetadata' | 'collections'> {
|
||||
return {
|
||||
readStatus: readStatus ?? null,
|
||||
audioMetadata: buildAudioMetadata(hasAudioFiles, narratorRows, audioMeta, chapters),
|
||||
comicMetadata: buildComicMetadata(comicMeta),
|
||||
collections,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user