mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
17aa7425f8
* feat(scanner): set book added_at from earliest on-disk file mtime A book's "date added" now reflects when its content files first landed on disk (the earliest mtime across its content-role files) instead of the moment BookOrbit imported it. Cover/metadata/supplement sidecars are excluded. When no content file has a usable mtime, addedAt is left unset so the DB defaultNow() fallback (import time) still applies. Only set at initial book creation; re-scan/restore paths do not touch it. * feat(library): add per-library added_at source with recompute Add an addedAtSource setting to libraries so "date added" can follow the book's files on disk rather than the moment the scanner happened to see them. The setting takes imported (unchanged default), file_modified, or file_created, guarded by a check constraint on the column. Expose a recompute action so the chosen source can be applied to books that are already imported. file_modified runs as a single set-based update over the stored mtimes; file_created has to stat the files, since birthtime is not persisted, so it batches those reads. Leave every existing library on imported so behavior is unchanged unless a user opts in. * feat(library): add added_at source control to the library editor Surface the new addedAtSource setting in the library editor as a radio group, alongside a button that triggers the recompute for the library being edited. Keep the recompute out of the save path so changing the setting stays cheap: the setting alone only affects books imported from then on, and applying it to existing books is an explicit choice. * i18n(library): translate added_at source strings (de/nl/pt/sl) * fix(library): correct create payload, recompute floor and failure logging The library creator posts its whole form, so omitting addedAtSource from CreateLibraryDto made the global whitelist reject every create request with a 400. Declare the field, validate it against the same source list as the update DTO, and persist it so the initial scan uses the requested source rather than silently falling back to imported. Apply the scanner's usable-time floor to the mtime recompute query too. It previously excluded only NULL, so a filesystem reporting epoch 0 would rewrite added_at to 1970 instead of leaving the import time in place. The floor now lives in library.constants rather than being spelled out twice. Wrap the recompute in try/catch so a database or stat failure emits the [fail] log the logging conventions require instead of propagating with only a [start] line recorded. * fix(library): gate recompute on the saved source and label the radios The recompute button stayed enabled while the selected source differed from the stored one. Because the request carries no source, clicking it recomputed against the old stored value while the UI showed the new selection. A hint already said to save first; disable the control as well so the mismatch is not actionable. Give the source list radiogroup semantics with aria-checked so screen readers can tell which option is active, since selection was conveyed by styling alone. * test(library): assert the create DTO under the real validation options The previous comment claimed the case exercised whitelist and forbidNonWhitelisted, but the shared helper calls validate without them, so it only proved @IsIn rejects an unknown value. Drop the misleading comment and add a case that validates with the same options main.ts gives ValidationPipe, so an undeclared addedAtSource fails the way it would in a real request. * docs(library): remove redundant date-added documentation --------- Co-authored-by: Bob Bobber <user@example.com> Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
601 lines
25 KiB
TypeScript
601 lines
25 KiB
TypeScript
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
import type { Library, LibraryLastScan, LibraryOverviewEntry, LibraryScanHistoryEntry } from '@bookorbit/types'
|
|
|
|
// --- Mocks (must be before imports that use them) ---
|
|
|
|
vi.mock('@/features/library/composables/useLibraries', () => ({
|
|
useLibraries: () => ({
|
|
libraries: librariesRef,
|
|
fetchLibraries: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
refreshLibraries: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/features/library/composables/useLibraryCreationRedirect', () => ({
|
|
useLibraryCreationRedirect: () => ({ handleLibraryCreated: vi.fn<() => void>() }),
|
|
}))
|
|
|
|
vi.mock('@/features/library/composables/useLibraryFileSync', () => ({
|
|
useLibraryFileSync: () => ({ syncAll: vi.fn<() => void>() }),
|
|
}))
|
|
|
|
vi.mock('@/features/scanner/composables/useScanProgress', () => ({
|
|
useScanProgress: () => ({
|
|
subscribeLibrary: vi.fn<() => void>(),
|
|
getProgress: (libraryId: number) => progressRef.value.get(libraryId),
|
|
isScanning: (libraryId: number) => progressRef.value.get(libraryId)?.status === 'running',
|
|
progressMap: progressRef,
|
|
getCoverRefreshProgress: vi.fn<() => undefined>().mockReturnValue(undefined),
|
|
isRefreshingCovers: vi.fn<() => boolean>().mockReturnValue(false),
|
|
}),
|
|
getSocket: vi.fn<() => void>(),
|
|
}))
|
|
|
|
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
|
usePermissions: () => ({ hasPermission: vi.fn<() => boolean>().mockReturnValue(true) }),
|
|
}))
|
|
|
|
vi.mock('@/lib/api', () => ({
|
|
api: (...args: unknown[]) => apiMock(...args),
|
|
}))
|
|
|
|
vi.mock('vue-sonner', () => ({
|
|
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
|
|
}))
|
|
|
|
vi.mock('@/features/library/components/LibraryCreatorModal.vue', () => ({
|
|
default: { template: '<div />' },
|
|
}))
|
|
|
|
// --- Module-level mutable state ---
|
|
|
|
import { ref } from 'vue'
|
|
import type { ScanProgressEvent } from '@bookorbit/types'
|
|
|
|
const librariesRef = ref<Library[]>([])
|
|
const progressRef = ref<Map<number, ScanProgressEvent>>(new Map())
|
|
const overviewRef = ref<LibraryOverviewEntry[]>([])
|
|
const historyRef = ref<LibraryScanHistoryEntry[]>([])
|
|
const accessRef = ref<{ userId: number }[]>([])
|
|
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
|
|
|
|
// --- Factory helpers ---
|
|
|
|
function makeLibrary(overrides: Partial<Library> = {}): Library {
|
|
return {
|
|
id: 1,
|
|
name: 'Test Library',
|
|
icon: null,
|
|
displayOrder: 0,
|
|
coverAspectRatio: '2/3',
|
|
watch: false,
|
|
autoScanCronExpression: null,
|
|
metadataPrecedence: [],
|
|
formatPriority: [],
|
|
allowedFormats: [],
|
|
organizationMode: 'book_per_file',
|
|
addedAtSource: 'imported',
|
|
excludePatterns: [],
|
|
readingThreshold: 10,
|
|
markAsFinishedPercentComplete: 90,
|
|
fileNamingPattern: null,
|
|
fileWriteEnabled: false,
|
|
fileWriteWriteCover: false,
|
|
fileWriteEpubEnabled: false,
|
|
fileWriteEpubMaxFileSizeMb: 100,
|
|
fileWriteFb2Enabled: false,
|
|
fileWriteFb2MaxFileSizeMb: 100,
|
|
fileWritePdfEnabled: false,
|
|
fileWritePdfMaxFileSizeMb: 100,
|
|
fileWriteCbxEnabled: false,
|
|
fileWriteCbxMaxFileSizeMb: 500,
|
|
fileWriteKindleEnabled: false,
|
|
fileWriteKindleMaxFileSizeMb: 100,
|
|
fileWriteAudioEnabled: false,
|
|
fileWriteAudioMaxFileSizeMb: 500,
|
|
fileRenameEnabled: false,
|
|
folders: [{ id: 1, path: '/books', createdAt: '2024-01-01T00:00:00.000Z' }],
|
|
createdAt: '2024-01-01T00:00:00.000Z',
|
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeScan(overrides: Partial<LibraryLastScan> = {}): LibraryLastScan {
|
|
return {
|
|
status: 'completed',
|
|
triggeredBy: 'manual',
|
|
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
addedCount: 0,
|
|
updatedCount: 0,
|
|
missingCount: 0,
|
|
errorMessage: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeEntry(overrides: Partial<LibraryOverviewEntry> = {}): LibraryOverviewEntry {
|
|
return { libraryId: 1, totalBooks: 0, totalSizeBytes: 0, formatCounts: {}, lastScan: null, ...overrides }
|
|
}
|
|
|
|
function makeRouter() {
|
|
return createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{ path: '/', component: { template: '<div />' } },
|
|
{ path: '/library/:id', name: 'library', component: { template: '<div />' } },
|
|
{ path: '/settings/appearance/theme', name: 'settings-appearance-theme', component: { template: '<div />' } },
|
|
],
|
|
})
|
|
}
|
|
|
|
import LibrariesSettings from '../LibrariesSettings.vue'
|
|
import LibraryRowActions from '../libraries/components/LibraryRowActions.vue'
|
|
|
|
/** Every mount is tracked so a leftover component's watchers cannot react to a later test's state. */
|
|
const mounted: ReturnType<typeof mount>[] = []
|
|
|
|
function mountComponent(options: { realTeleport?: boolean } = {}) {
|
|
const wrapper = mount(LibrariesSettings, {
|
|
attachTo: document.body,
|
|
global: {
|
|
plugins: [makeRouter()],
|
|
stubs: {
|
|
// Dialogs portal to the body, so the delete test opts into the real Teleport.
|
|
...(options.realTeleport ? {} : { Teleport: true as const }),
|
|
TooltipProvider: { template: '<div><slot /></div>' },
|
|
Tooltip: { template: '<div><slot /></div>' },
|
|
TooltipTrigger: { template: '<div><slot /></div>' },
|
|
TooltipContent: { template: '<div><slot /></div>' },
|
|
},
|
|
},
|
|
})
|
|
mounted.push(wrapper)
|
|
return wrapper
|
|
}
|
|
|
|
/** The ledger and the mobile cards both render under jsdom, so assertions read the ledger alone. */
|
|
function tableText(wrapper: ReturnType<typeof mountComponent>): string {
|
|
return wrapper.get('[data-testid="libraries-ledger-list"]').text()
|
|
}
|
|
|
|
function ledgerRows(wrapper: ReturnType<typeof mountComponent>) {
|
|
return wrapper.findAll('[data-testid="library-row-toggle"]').map((toggle) => toggle.element.closest('.rounded-xl')!)
|
|
}
|
|
|
|
async function mountLoaded(options: { realTeleport?: boolean } = {}) {
|
|
const wrapper = mountComponent(options)
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
describe('LibrariesSettings ledger', () => {
|
|
afterEach(() => {
|
|
while (mounted.length > 0) mounted.pop()?.unmount()
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
librariesRef.value = []
|
|
progressRef.value = new Map()
|
|
overviewRef.value = []
|
|
historyRef.value = []
|
|
accessRef.value = []
|
|
apiMock.mockImplementation(async (path: unknown) => {
|
|
const url = String(path)
|
|
if (url.endsWith('/libraries/overview')) return { ok: true, json: async () => overviewRef.value }
|
|
if (url.includes('/scan-history')) return { ok: true, json: async () => historyRef.value }
|
|
if (url.endsWith('/access')) return { ok: true, json: async () => accessRef.value }
|
|
return { ok: true, json: async () => ({}) }
|
|
})
|
|
})
|
|
|
|
describe('stats loading', () => {
|
|
it('loads every library from a single overview request instead of one call each', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 1 }), makeLibrary({ id: 2, name: 'Second' }), makeLibrary({ id: 3, name: 'Third' })]
|
|
await mountLoaded()
|
|
const statsCalls = apiMock.mock.calls.filter((call) => String(call[0]).includes('stats') || String(call[0]).includes('overview'))
|
|
expect(statsCalls).toHaveLength(1)
|
|
expect(statsCalls[0]?.[0]).toBe('/api/v1/libraries/overview')
|
|
})
|
|
|
|
it('surfaces an error instead of leaving rows silently blank when the overview fails', async () => {
|
|
librariesRef.value = [makeLibrary()]
|
|
apiMock.mockResolvedValue({ ok: false, json: async () => ({}) })
|
|
const wrapper = await mountLoaded()
|
|
expect(wrapper.get('[role="alert"]').text()).toContain('Could not load library counts')
|
|
})
|
|
|
|
it('renders counts and size from the overview payload', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 99 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 99, totalBooks: 42, totalSizeBytes: 5 * 1024 * 1024 })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('42')
|
|
expect(tableText(wrapper)).toContain('5 MB')
|
|
})
|
|
})
|
|
|
|
describe('identity cell', () => {
|
|
it('shows the organization mode', async () => {
|
|
librariesRef.value = [makeLibrary({ organizationMode: 'book_per_folder' })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('Folder mode')
|
|
expect(tableText(wrapper)).not.toContain('File mode')
|
|
})
|
|
|
|
it('prints the whole folder path in the ledger rather than hiding it behind a hover', async () => {
|
|
librariesRef.value = [makeLibrary({ folders: [{ id: 1, path: '/srv/media/books/novels', createdAt: '2024-01-01T00:00:00.000Z' }] })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('/srv/media/books/novels')
|
|
})
|
|
|
|
it('keeps the identifying tail of the path on the narrower mobile card', async () => {
|
|
librariesRef.value = [makeLibrary({ folders: [{ id: 1, path: '/srv/media/books/novels', createdAt: '2024-01-01T00:00:00.000Z' }] })]
|
|
const wrapper = await mountLoaded()
|
|
expect(wrapper.get('[data-testid="libraries-ledger-cards"]').text()).toContain('…/books/novels')
|
|
})
|
|
|
|
it('counts the remaining folders and keeps every path in the tooltip', async () => {
|
|
librariesRef.value = [
|
|
makeLibrary({
|
|
folders: [
|
|
{ id: 1, path: '/books/fiction', createdAt: '2024-01-01T00:00:00.000Z' },
|
|
{ id: 2, path: '/books/nonfiction', createdAt: '2024-01-01T00:00:00.000Z' },
|
|
],
|
|
}),
|
|
]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('+1 folder')
|
|
expect(wrapper.html()).toContain('/books/fiction')
|
|
expect(wrapper.html()).toContain('/books/nonfiction')
|
|
})
|
|
})
|
|
|
|
describe('automation', () => {
|
|
it('spells out all four settings with their state, on or off', async () => {
|
|
librariesRef.value = [makeLibrary({ watch: true, autoScanCronExpression: null, fileWriteEnabled: false, fileRenameEnabled: true })]
|
|
const wrapper = await mountLoaded()
|
|
const items = wrapper.get('[data-testid="libraries-ledger-list"]').findAll('li')
|
|
expect(items).toHaveLength(4)
|
|
expect(items.map((item) => item.text().replace(/\s+/g, ' '))).toEqual([
|
|
'Watch foldersOn',
|
|
'Scheduled scanOff',
|
|
'Write to fileOff',
|
|
'Rename filesOn',
|
|
])
|
|
})
|
|
|
|
it('distinguishes on from off by more than the word, so the state is not colour-only', async () => {
|
|
librariesRef.value = [makeLibrary({ watch: true, fileWriteEnabled: false })]
|
|
const wrapper = await mountLoaded()
|
|
const [watchRow, , writeRow] = wrapper.get('[data-testid="libraries-ledger-list"]').findAll('li')
|
|
const chip = (row: typeof watchRow) => row.findAll('span').find((span) => span.classes().includes('rounded-full'))!
|
|
expect(chip(watchRow!).classes().join(' ')).toContain('bg-primary/12')
|
|
expect(chip(writeRow!).classes().join(' ')).toContain('border-dashed')
|
|
})
|
|
|
|
it('shows the real schedule instead of a bare "on"', async () => {
|
|
librariesRef.value = [makeLibrary({ autoScanCronExpression: '0 2 * * *' })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('02:00 AM')
|
|
})
|
|
})
|
|
|
|
describe('format bar', () => {
|
|
it('uses formatCounts the row has room for, as a labelled bar', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 7 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 7, totalBooks: 381, formatCounts: { epub: 305, azw3: 34, mobi: 23 } })]
|
|
const wrapper = await mountLoaded()
|
|
const bar = wrapper.get('[data-testid="libraries-ledger-list"] [role="img"]')
|
|
expect(bar.attributes('aria-label')).toBe('EPUB 305, AZW3 34, MOBI 23')
|
|
expect(bar.findAll('span')).toHaveLength(3)
|
|
})
|
|
|
|
it('spells the breakdown out on mobile, where there is width for it', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 7 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 7, totalBooks: 381, formatCounts: { epub: 305, azw3: 34, mobi: 23, fb2: 13 } })]
|
|
const wrapper = await mountLoaded()
|
|
const cards = wrapper.get('[data-testid="libraries-ledger-cards"]').text()
|
|
expect(cards).toContain('EPUB')
|
|
expect(cards).toContain('305')
|
|
expect(cards).toContain('+2 more formats')
|
|
})
|
|
|
|
it('keeps an empty library on the same track rather than swapping in a text line', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 7 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 7 })]
|
|
const wrapper = await mountLoaded()
|
|
const bar = wrapper.get('[data-testid="libraries-ledger-list"] [role="img"]')
|
|
expect(bar.attributes('aria-label')).toBe('No files indexed')
|
|
expect(bar.findAll('span')).toHaveLength(0)
|
|
expect(wrapper.get('[data-testid="libraries-ledger-cards"]').text()).toContain('No files indexed')
|
|
})
|
|
})
|
|
|
|
describe('last scan', () => {
|
|
it('reports a completed scan with its trigger and deltas', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 4 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan({ triggeredBy: 'watcher', addedCount: 3 }) })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('Watcher')
|
|
expect(tableText(wrapper)).toContain('+3 added')
|
|
})
|
|
|
|
it('says "no change" rather than going blank when a scan found nothing', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 4 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan({ triggeredBy: 'schedule' }) })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('no change')
|
|
})
|
|
|
|
it('flags a library that has never been scanned', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 4 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: null })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('Never scanned')
|
|
expect(ledgerRows(wrapper)[0]!.className).toContain('pill-warning')
|
|
})
|
|
|
|
it('shows the failure and its error text', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 4 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan({ status: 'failed', errorMessage: 'ENOENT: /books' }) })]
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('Failed')
|
|
expect(tableText(wrapper)).toContain('ENOENT: /books')
|
|
expect(ledgerRows(wrapper)[0]!.className).toContain('destructive')
|
|
})
|
|
|
|
it('replaces the cell with live progress while a scan runs, without adding a row', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 4 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4, lastScan: makeScan() })]
|
|
progressRef.value = new Map([[4, { jobId: 1, libraryId: 4, status: 'running', processed: 620, total: 1000, added: 0, updated: 0, missing: 0 }]])
|
|
const wrapper = await mountLoaded()
|
|
expect(tableText(wrapper)).toContain('Scanning 62%')
|
|
expect(tableText(wrapper)).toContain('620 of 1,000')
|
|
expect(ledgerRows(wrapper)).toHaveLength(1)
|
|
expect(wrapper.get('[data-testid="libraries-ledger-list"] [role="progressbar"]').attributes('aria-valuenow')).toBe('62')
|
|
})
|
|
})
|
|
|
|
describe('filtering and sorting', () => {
|
|
beforeEach(() => {
|
|
librariesRef.value = [
|
|
makeLibrary({ id: 1, name: 'Novels', folders: [{ id: 1, path: '/srv/novels', createdAt: '2024-01-01T00:00:00.000Z' }] }),
|
|
makeLibrary({ id: 2, name: 'Comics', folders: [{ id: 2, path: '/srv/comics', createdAt: '2024-01-01T00:00:00.000Z' }] }),
|
|
]
|
|
overviewRef.value = [makeEntry({ libraryId: 1, totalBooks: 381 }), makeEntry({ libraryId: 2, totalBooks: 23 })]
|
|
})
|
|
|
|
it('filters by name', async () => {
|
|
const wrapper = await mountLoaded()
|
|
await wrapper.get('input[type="search"]').setValue('comi')
|
|
expect(tableText(wrapper)).toContain('Comics')
|
|
expect(tableText(wrapper)).not.toContain('Novels')
|
|
})
|
|
|
|
it('filters by folder path', async () => {
|
|
const wrapper = await mountLoaded()
|
|
await wrapper.get('input[type="search"]').setValue('/srv/novels')
|
|
expect(tableText(wrapper)).toContain('Novels')
|
|
expect(tableText(wrapper)).not.toContain('Comics')
|
|
})
|
|
|
|
it('offers a way back when nothing matches', async () => {
|
|
const wrapper = await mountLoaded()
|
|
await wrapper.get('input[type="search"]').setValue('nothing here')
|
|
expect(wrapper.text()).toContain('No libraries match that filter')
|
|
expect(wrapper.find('[data-testid="libraries-ledger-list"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('reorders rows when the sort changes', async () => {
|
|
const wrapper = await mountLoaded()
|
|
const names = () => ledgerRows(wrapper).map((row) => row.textContent ?? '')
|
|
expect(names()[0]).toContain('Novels')
|
|
await wrapper.get('select').setValue('name')
|
|
expect(names()[0]).toContain('Comics')
|
|
})
|
|
|
|
it('summarises the collection in the toolbar', async () => {
|
|
const wrapper = await mountLoaded()
|
|
expect(wrapper.text()).toContain('2 libraries')
|
|
expect(wrapper.text()).toContain('404 books')
|
|
expect(wrapper.text()).toContain('2 folders')
|
|
})
|
|
})
|
|
|
|
describe('empty state', () => {
|
|
it('shows the empty state when there are no libraries', async () => {
|
|
const wrapper = await mountLoaded()
|
|
expect(wrapper.text()).toContain('No libraries yet')
|
|
})
|
|
|
|
it('hides the empty state when libraries exist', async () => {
|
|
librariesRef.value = [makeLibrary()]
|
|
const wrapper = await mountLoaded()
|
|
expect(wrapper.text()).not.toContain('No libraries yet')
|
|
})
|
|
})
|
|
|
|
describe('scan completion', () => {
|
|
it('reacts once per finished job however many socket ticks carry it', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 4 })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4 })]
|
|
const wrapper = await mountLoaded()
|
|
const overviewCalls = () => apiMock.mock.calls.filter((call) => String(call[0]).endsWith('/libraries/overview')).length
|
|
const before = overviewCalls()
|
|
|
|
const done: ScanProgressEvent = { jobId: 77, libraryId: 4, status: 'completed', processed: 5, total: 5, added: 1, updated: 0, missing: 0 }
|
|
progressRef.value = new Map([[4, done]])
|
|
await flushPromises()
|
|
// The same completed event is re-published while it lingers in the map.
|
|
progressRef.value = new Map([[4, { ...done }]])
|
|
await flushPromises()
|
|
progressRef.value = new Map([[4, { ...done }]])
|
|
await flushPromises()
|
|
|
|
// The overview reload is debounced by 750ms.
|
|
await new Promise((resolve) => setTimeout(resolve, 900))
|
|
await flushPromises()
|
|
|
|
expect(overviewCalls() - before).toBe(1)
|
|
wrapper.unmount()
|
|
})
|
|
})
|
|
|
|
describe('detail panel', () => {
|
|
beforeEach(() => {
|
|
librariesRef.value = [makeLibrary({ id: 4, name: 'Novels' })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4, totalBooks: 381, lastScan: makeScan() })]
|
|
})
|
|
|
|
async function expandFirst(wrapper: ReturnType<typeof mountComponent>) {
|
|
await wrapper.get('[data-testid="library-row-toggle"]').trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
it('fetches nothing until a row is opened', async () => {
|
|
const wrapper = await mountLoaded()
|
|
expect(apiMock.mock.calls.filter((call) => String(call[0]).includes('scan-history'))).toHaveLength(0)
|
|
await expandFirst(wrapper)
|
|
expect(apiMock.mock.calls.filter((call) => String(call[0]).includes('scan-history'))).toHaveLength(1)
|
|
})
|
|
|
|
it('caps the history request so a caller cannot ask for an unbounded page', async () => {
|
|
const wrapper = await mountLoaded()
|
|
await expandFirst(wrapper)
|
|
const call = apiMock.mock.calls.find((c) => String(c[0]).includes('scan-history'))
|
|
expect(String(call?.[0])).toBe('/api/v1/scanner/libraries/4/scan-history?limit=5')
|
|
})
|
|
|
|
it('shows configuration the row cannot hold', async () => {
|
|
librariesRef.value = [
|
|
makeLibrary({
|
|
id: 4,
|
|
name: 'Novels',
|
|
excludePatterns: ['*.tmp'],
|
|
metadataPrecedence: ['embedded', 'opfFile'],
|
|
readingThreshold: 0.25,
|
|
markAsFinishedPercentComplete: 98,
|
|
}),
|
|
]
|
|
accessRef.value = [{ userId: 1 }, { userId: 2 }]
|
|
const wrapper = await mountLoaded()
|
|
await expandFirst(wrapper)
|
|
const panel = wrapper.get('[id="library-detail-4"]').text()
|
|
expect(panel).toContain('Embedded metadata, OPF files')
|
|
expect(panel).toContain('All supported')
|
|
expect(panel).toContain('1 pattern')
|
|
expect(panel).toContain('25%')
|
|
expect(panel).toContain('98%')
|
|
expect(panel).toContain('2 people')
|
|
})
|
|
|
|
it('renders the scan history with trigger and deltas', async () => {
|
|
historyRef.value = [
|
|
{ id: 9, ...makeScan({ triggeredBy: 'watcher', addedCount: 3, updatedCount: 1 }) },
|
|
{ id: 8, ...makeScan({ status: 'failed', errorMessage: 'ENOENT: /books' }) },
|
|
]
|
|
const wrapper = await mountLoaded()
|
|
await expandFirst(wrapper)
|
|
const panel = wrapper.get('[id="library-detail-4"]').text()
|
|
expect(panel).toContain('Watcher')
|
|
expect(panel).toContain('+3 added')
|
|
expect(panel).toContain('ENOENT: /books')
|
|
expect(panel).toContain('Failed')
|
|
expect(panel).toContain('Completed')
|
|
})
|
|
|
|
it('says so when a library has never been scanned', async () => {
|
|
const wrapper = await mountLoaded()
|
|
await expandFirst(wrapper)
|
|
expect(wrapper.get('[id="library-detail-4"]').text()).toContain('never been scanned')
|
|
})
|
|
|
|
it('surfaces a failed detail fetch instead of showing an empty panel', async () => {
|
|
apiMock.mockImplementation(async (path: unknown) => {
|
|
const url = String(path)
|
|
if (url.endsWith('/libraries/overview')) return { ok: true, json: async () => overviewRef.value }
|
|
return { ok: false, json: async () => ({}) }
|
|
})
|
|
const wrapper = await mountLoaded()
|
|
await expandFirst(wrapper)
|
|
expect(wrapper.get('[id="library-detail-4"]').text()).toContain('Could not load the scan history')
|
|
})
|
|
|
|
it('opens one row at a time and closes on a second click', async () => {
|
|
librariesRef.value = [makeLibrary({ id: 4, name: 'Novels' }), makeLibrary({ id: 5, name: 'Comics' })]
|
|
overviewRef.value = [makeEntry({ libraryId: 4 }), makeEntry({ libraryId: 5 })]
|
|
const wrapper = await mountLoaded()
|
|
const toggles = () => wrapper.findAll('[data-testid="library-row-toggle"]')
|
|
|
|
await toggles()[0]!.trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.find('[id="library-detail-4"]').exists()).toBe(true)
|
|
|
|
await toggles()[1]!.trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.find('[id="library-detail-4"]').exists()).toBe(false)
|
|
expect(wrapper.find('[id="library-detail-5"]').exists()).toBe(true)
|
|
|
|
await toggles()[1]!.trigger('click')
|
|
await flushPromises()
|
|
expect(wrapper.find('[id="library-detail-5"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('marks the toggle as controlling its panel', async () => {
|
|
const wrapper = await mountLoaded()
|
|
const toggle = wrapper.get('[data-testid="library-row-toggle"]')
|
|
expect(toggle.attributes('aria-expanded')).toBe('false')
|
|
expect(toggle.attributes('aria-controls')).toBe('library-detail-4')
|
|
await expandFirst(wrapper)
|
|
expect(wrapper.get('[data-testid="library-row-toggle"]').attributes('aria-expanded')).toBe('true')
|
|
})
|
|
})
|
|
|
|
describe('delete confirmation', () => {
|
|
it('uses the shared dialog and only enables delete once the name matches', async () => {
|
|
const headerTarget = document.createElement('div')
|
|
headerTarget.id = 'settings-header-actions'
|
|
document.body.appendChild(headerTarget)
|
|
librariesRef.value = [makeLibrary({ id: 5, name: 'Novels' })]
|
|
const wrapper = await mountLoaded({ realTeleport: true })
|
|
|
|
wrapper.findComponent(LibraryRowActions).vm.$emit('remove', librariesRef.value[0]!)
|
|
await flushPromises()
|
|
|
|
const dialog = document.querySelector('[role="dialog"]')
|
|
expect(dialog).not.toBeNull()
|
|
expect(dialog!.textContent).toContain('Delete "Novels"?')
|
|
|
|
const confirmButton = () => [...dialog!.querySelectorAll('button')].find((button) => button.textContent?.includes('Delete Library'))
|
|
expect(confirmButton()?.disabled).toBe(true)
|
|
|
|
const input = dialog!.querySelector('input') as HTMLInputElement
|
|
input.value = 'Novels'
|
|
input.dispatchEvent(new Event('input'))
|
|
await flushPromises()
|
|
expect(confirmButton()?.disabled).toBe(false)
|
|
|
|
wrapper.unmount()
|
|
headerTarget.remove()
|
|
})
|
|
|
|
it('puts the page actions in the settings header slot when one exists', async () => {
|
|
const headerTarget = document.createElement('div')
|
|
headerTarget.id = 'settings-header-actions'
|
|
document.body.appendChild(headerTarget)
|
|
librariesRef.value = [makeLibrary()]
|
|
const wrapper = await mountLoaded({ realTeleport: true })
|
|
|
|
expect(headerTarget.textContent).toContain('Add Library')
|
|
expect(headerTarget.textContent).toContain('Scan All')
|
|
|
|
wrapper.unmount()
|
|
headerTarget.remove()
|
|
})
|
|
})
|
|
})
|