fix: add book:restored toast and fix missing events in reconcile

This commit is contained in:
neon
2026-03-28 09:21:47 -06:00
parent af9424f04a
commit 31422c9054
5 changed files with 88 additions and 2 deletions
@@ -13,7 +13,11 @@ vi.mock('@/features/scanner/composables/useScanProgress', () => ({
}))
vi.mock('vue-sonner', () => ({
toast: { warning: vi.fn<(message: string) => void>() },
toast: {
warning: vi.fn<(message: string) => void>(),
success: vi.fn<(message: string) => void>(),
info: vi.fn<(message: string) => void>(),
},
}))
// Import the composable after mocks are in place.
@@ -21,6 +25,8 @@ import { useBookEvents } from '@/features/book/composables/useBookEvents'
// Shorthand to the hoisted mock fn — accessed via vi.mocked after module loads.
const toastWarning = vi.mocked(toast.warning)
const toastSuccess = vi.mocked(toast.success)
const toastInfo = vi.mocked(toast.info)
beforeAll(() => {
useBookEvents() // triggers ensureInitialized(), registers socket listeners
@@ -29,6 +35,8 @@ beforeAll(() => {
beforeEach(() => {
vi.useFakeTimers()
toastWarning.mockClear()
toastSuccess.mockClear()
toastInfo.mockClear()
vi.clearAllTimers()
})
@@ -93,3 +101,47 @@ describe('onBookMissing', () => {
cleanup()
})
})
// ── onBookRestored ─────────────────────────────────────────────────────────────
describe('onBookRestored', () => {
it('fires the callback with the bookIds array on book:restored event', () => {
const { onBookRestored } = useBookEvents()
const cb = vi.fn<(bookIds: number[]) => void>()
const cleanup = onBookRestored(cb)
mockSocket.emit('book:restored', { libraryId: 1, bookIds: [77] })
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenCalledWith([77])
cleanup()
})
it('shows success toast for 1 restored book after 1s debounce', () => {
const { onBookRestored } = useBookEvents()
const cleanup = onBookRestored(() => {})
mockSocket.emit('book:restored', { libraryId: 1, bookIds: [123] })
expect(toastSuccess).not.toHaveBeenCalled()
vi.advanceTimersByTime(1000)
expect(toastSuccess).toHaveBeenCalledTimes(1)
expect(toastSuccess).toHaveBeenCalledWith('1 book was restored on disk.')
cleanup()
})
it('batches multiple events into one success toast', () => {
const { onBookRestored } = useBookEvents()
const cleanup = onBookRestored(() => {})
mockSocket.emit('book:restored', { libraryId: 1, bookIds: [1, 2] })
mockSocket.emit('book:restored', { libraryId: 1, bookIds: [3] })
vi.advanceTimersByTime(1000)
expect(toastSuccess).toHaveBeenCalledTimes(1)
expect(toastSuccess).toHaveBeenCalledWith('3 books were restored on disk.')
cleanup()
})
})
@@ -9,8 +9,10 @@ const restoredCallbacks = new Set<BookIdsCallback>()
const movedCallbacks = new Set<BookIdsCallback>()
let pendingMissingCount = 0
let pendingRestoredCount = 0
let pendingMovedCount = 0
let missingToastTimer: ReturnType<typeof setTimeout> | null = null
let restoredToastTimer: ReturnType<typeof setTimeout> | null = null
let movedToastTimer: ReturnType<typeof setTimeout> | null = null
function flushMissingToast() {
@@ -21,6 +23,14 @@ function flushMissingToast() {
toast.warning(count === 1 ? '1 book is no longer available on disk.' : `${count} books are no longer available on disk.`)
}
function flushRestoredToast() {
if (pendingRestoredCount === 0) return
const count = pendingRestoredCount
pendingRestoredCount = 0
restoredToastTimer = null
toast.success(count === 1 ? '1 book was restored on disk.' : `${count} books were restored on disk.`)
}
function flushMovedToast() {
if (pendingMovedCount === 0) return
const count = pendingMovedCount
@@ -46,6 +56,9 @@ function ensureInitialized() {
socket.on('book:restored', (event: BookRestoredEvent) => {
for (const cb of restoredCallbacks) cb(event.bookIds)
pendingRestoredCount += event.bookIds.length
clearTimeout(restoredToastTimer ?? undefined)
restoredToastTimer = setTimeout(flushRestoredToast, 1000)
})
socket.on('book:moved', (event: BookMovedEvent) => {
@@ -163,6 +163,20 @@ describe('reconcile()', () => {
expect(gateway.emitBookRestored).toHaveBeenCalledWith({ libraryId: 2, bookIds: [20] });
});
it('emits book-missing and book-moved for each result from reconcileMissingBooks', async () => {
const { service, processor, gateway } = makeService();
(service as any).subscriptions.set(1, []);
(processor.reconcileMissingBooks as vi.Mock).mockResolvedValue([
{ type: 'book-missing', libraryId: 1, bookIds: [20] },
{ type: 'book-moved', libraryId: 1, bookIds: [30] },
]);
await (service as any).reconcile();
expect(gateway.emitBookMissing).toHaveBeenCalledWith({ libraryId: 1, bookIds: [20] });
expect(gateway.emitBookMoved).toHaveBeenCalledWith({ libraryId: 1, bookIds: [30] });
});
it('does nothing when reconcileMissingBooks returns empty', async () => {
const { service, gateway } = makeService();
(service as any).subscriptions.set(1, []);
@@ -103,8 +103,12 @@ export class FileWatcherService implements OnApplicationBootstrap, OnModuleDestr
try {
const results = await this.processor.reconcileMissingBooks(libraryIds);
for (const result of results) {
if (result.type === 'book-restored') {
if (result.type === 'book-missing') {
this.gateway.emitBookMissing({ libraryId: result.libraryId, bookIds: result.bookIds });
} else if (result.type === 'book-restored') {
this.gateway.emitBookRestored({ libraryId: result.libraryId, bookIds: result.bookIds });
} else if (result.type === 'book-moved') {
this.gateway.emitBookMoved({ libraryId: result.libraryId, bookIds: result.bookIds });
}
}
this.logger.log(
@@ -81,14 +81,17 @@ export class ScanGateway implements OnGatewayConnection, OnGatewayDisconnect {
}
emitBookMissing(event: BookMissingEvent): void {
this.logger.debug(`[scanner.ws_emit] [book:missing] libraryId=${event.libraryId} bookCount=${event.bookIds.length}`);
this.server?.to(`library:${event.libraryId}`).emit('book:missing', event);
}
emitBookRestored(event: BookRestoredEvent): void {
this.logger.debug(`[scanner.ws_emit] [book:restored] libraryId=${event.libraryId} bookCount=${event.bookIds.length}`);
this.server?.to(`library:${event.libraryId}`).emit('book:restored', event);
}
emitBookMoved(event: BookMovedEvent): void {
this.logger.debug(`[scanner.ws_emit] [book:moved] libraryId=${event.libraryId} bookCount=${event.bookIds.length}`);
this.server?.to(`library:${event.libraryId}`).emit('book:moved', event);
}
}