mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix: add book:restored toast and fix missing events in reconcile
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user