mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
59e3f36f5e
Library access roles had no effect on metadata editing: only the global Edit metadata permission was checked, so a Viewer could edit every library they could see and an Editor could not be limited to one library. Require Editor or Owner on the book's library for every metadata write, including bulk edits, locks, covers, file renames, entity manager changes and author edits, and hide the matching actions in the web client. Keep existing access levels when an admin saves a user's library list. The old replace reset every row to Viewer, which would now silently revoke editing on each user save. Existing grants are Viewer, so after upgrading, admins must set Editor on each library where a non-superuser should keep editing metadata. The released iOS app already gates editing on Editor. Closes #1778
827 lines
34 KiB
TypeScript
827 lines
34 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import { defineComponent, h, ref } from 'vue'
|
|
import type { BookCard } from '@bookorbit/types'
|
|
import BookCoverCard from './BookCoverCard.vue'
|
|
|
|
// -- module mocks (must precede imports) -------------------------------------
|
|
|
|
const mockToastError = vi.hoisted(() => vi.fn<(message: string) => void>())
|
|
vi.mock('vue-sonner', () => ({ toast: { error: mockToastError, success: vi.fn<(message: string) => void>() } }))
|
|
|
|
const mockRouterPush = vi.fn<() => void>()
|
|
vi.mock('vue-router', () => ({
|
|
useRoute: () => ({ fullPath: '/' }),
|
|
useRouter: () => ({ push: mockRouterPush }),
|
|
}))
|
|
|
|
const mockCoverUrl = vi.fn<(id: number, type?: 'thumbnail' | 'cover', sourceVersion?: string | number | Date | null) => string>(
|
|
(id: number) => `/api/covers/${id}`,
|
|
)
|
|
const mockBumpVersion = vi.fn<() => void>()
|
|
vi.mock('@/features/book/composables/useCoverVersions', () => ({
|
|
useCoverVersions: () => ({ coverUrl: mockCoverUrl, bumpVersion: mockBumpVersion }),
|
|
}))
|
|
|
|
const mockRefreshWithFeedback = vi.fn<() => Promise<null>>().mockResolvedValue(null)
|
|
const mockRefreshing = ref(false)
|
|
vi.mock('@/features/book/composables/useRefreshMetadata', () => ({
|
|
useRefreshMetadata: () => ({ refreshing: mockRefreshing, refreshWithFeedback: mockRefreshWithFeedback }),
|
|
}))
|
|
|
|
const mockIsRefreshing = vi.fn<() => boolean>().mockReturnValue(false)
|
|
vi.mock('@/features/book/composables/useRefreshingBooks', () => ({
|
|
useRefreshingBooks: () => ({ isRefreshing: mockIsRefreshing }),
|
|
}))
|
|
|
|
vi.mock('@/features/book/lib/book-card-mapper', () => ({
|
|
mergeBookCardWithDetail: vi.fn<(_card: unknown, detail: unknown) => unknown>((_card: unknown, detail: unknown) => detail),
|
|
}))
|
|
|
|
const mockHasPermission = vi.fn<() => boolean>().mockReturnValue(true)
|
|
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
|
usePermissions: () => ({ hasPermission: mockHasPermission, isSuperuser: ref(false) }),
|
|
}))
|
|
vi.mock('@/features/library/composables/useLibraries', () => ({
|
|
useLibraries: () => ({ libraries: ref([{ id: 7, accessLevel: 'editor' }]) }),
|
|
}))
|
|
|
|
const mockFetchAuthors = vi.hoisted(() =>
|
|
vi.fn<
|
|
(params: { q?: string; page: number; size: number; sort: string; order: string }) => Promise<{
|
|
items: Array<{ id: number; name: string; sortName: string | null; bookCount: number; lastAddedAt: string | null }>
|
|
total: number
|
|
page: number
|
|
size: number
|
|
}>
|
|
>(),
|
|
)
|
|
vi.mock('@/features/author/api/author', () => ({
|
|
fetchAuthors: mockFetchAuthors,
|
|
}))
|
|
|
|
const mockCardOverlays = ref<string[]>([])
|
|
const mockBookCoverDisplayMode = ref('blurred-fit')
|
|
const mockGridCardPrimaryLabel = ref('hidden')
|
|
const mockGridCardSecondaryLabel = ref('hidden')
|
|
const mockCardInfoMode = ref('hover-overlay')
|
|
const mockThumbnailClickAction = ref('reader')
|
|
vi.mock('@/composables/useDisplaySettings', () => ({
|
|
useDisplaySettings: () => ({
|
|
cardOverlays: mockCardOverlays,
|
|
bookSpineOverlay: ref('off'),
|
|
bookShadowStrength: ref('default'),
|
|
bookCoverDisplayMode: mockBookCoverDisplayMode,
|
|
gridCardPrimaryLabel: mockGridCardPrimaryLabel,
|
|
gridCardSecondaryLabel: mockGridCardSecondaryLabel,
|
|
cardInfoMode: mockCardInfoMode,
|
|
thumbnailClickAction: mockThumbnailClickAction,
|
|
}),
|
|
}))
|
|
|
|
type ExportScope = 'primary' | 'all' | 'audio'
|
|
|
|
const mockDownloadFile = vi.fn<(fileId: number) => void>()
|
|
const mockExportBooks = vi.fn<(bookIds: number[], allFormats: boolean, scopeOverride?: ExportScope) => void>()
|
|
vi.mock('@/features/book/composables/useBookDownload', () => ({
|
|
useBookDownload: () => ({ downloadFile: mockDownloadFile, exportBooks: mockExportBooks }),
|
|
}))
|
|
|
|
const mockSetStatus = vi.fn<() => void>()
|
|
vi.mock('@/features/book/composables/useBookStatus', () => ({
|
|
useBookStatus: () => ({ setStatus: mockSetStatus }),
|
|
STATUS_OPTIONS: [
|
|
{ value: 'unread', label: 'Unread' },
|
|
{ value: 'reading', label: 'Reading' },
|
|
{ value: 'read', label: 'Read' },
|
|
],
|
|
STATUS_ICONS: { unread: 'span', reading: 'span', read: 'span' },
|
|
STATUS_COLORS: { unread: 'text-muted', reading: 'text-blue-500', read: 'text-emerald-500' },
|
|
}))
|
|
|
|
vi.mock('@/features/book/lib/book-cover', () => ({
|
|
bookCoverStyle: (_seed: string) => ({ background: 'oklch(50% 0.1 200)' }),
|
|
}))
|
|
|
|
vi.mock('@/features/book/lib/format-colors', () => ({
|
|
getFormatColor: () => '#4a90e2',
|
|
}))
|
|
|
|
vi.mock('@/features/book/components/BookCoverPlaceholder.vue', () => ({
|
|
default: defineComponent({
|
|
name: 'BookCoverPlaceholder',
|
|
props: ['title', 'authorLine', 'isAudio', 'seed'],
|
|
setup(props) {
|
|
return () => h('div', { 'data-testid': 'cover-placeholder', 'data-title': props.title })
|
|
},
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/features/email/components/SendBookDialog.vue', () => ({
|
|
default: defineComponent({
|
|
name: 'SendBookDialog',
|
|
props: ['open', 'selectionPayload', 'bookFiles', 'bookTitle'],
|
|
emits: ['update:open'],
|
|
setup() {
|
|
return () => h('div', { 'data-testid': 'send-dialog' })
|
|
},
|
|
}),
|
|
}))
|
|
|
|
// -- helpers -----------------------------------------------------------------
|
|
|
|
function makeFile(overrides: Partial<{ id: number; format: string; role: string; sizeBytes: number }> = {}) {
|
|
return {
|
|
id: overrides.id ?? 1,
|
|
format: overrides.format ?? 'epub',
|
|
role: overrides.role ?? 'primary',
|
|
sizeBytes: overrides.sizeBytes ?? 1024,
|
|
}
|
|
}
|
|
|
|
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
|
|
return {
|
|
id: 1,
|
|
libraryId: 7,
|
|
status: 'present',
|
|
coverAspectRatio: '2/3',
|
|
title: 'Dune',
|
|
authors: ['Frank Herbert'],
|
|
seriesId: null,
|
|
seriesName: null,
|
|
seriesIndex: null,
|
|
files: [makeFile()],
|
|
publishedDate: null,
|
|
publishedYear: 1965,
|
|
language: 'en',
|
|
genres: ['Science Fiction'],
|
|
rating: 4,
|
|
readingProgress: 50,
|
|
readStatus: null,
|
|
addedAt: '2024-01-01T00:00:00Z',
|
|
updatedAt: null,
|
|
coverVersion: 'legacy:2024-01-01T00:00:00Z',
|
|
metadataScore: 90,
|
|
hasCover: false,
|
|
hasMetadataLocks: false,
|
|
lockedFields: [],
|
|
subtitle: null,
|
|
publisher: 'Chilton Books',
|
|
pageCount: 412,
|
|
isbn13: '9780441013593',
|
|
narrators: [],
|
|
customMetadata: [],
|
|
tags: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeReadStatus(status: NonNullable<BookCard['readStatus']>['status']): NonNullable<BookCard['readStatus']> {
|
|
return {
|
|
status,
|
|
source: 'auto',
|
|
startedAt: null,
|
|
finishedAt: status === 'read' ? '2026-09-21T00:00:00.000Z' : null,
|
|
updatedAt: '2026-09-21T00:00:00.000Z',
|
|
}
|
|
}
|
|
|
|
function mountCard(
|
|
props: Partial<{ book: BookCard; selectionMode: boolean; selected: boolean; showLabel: boolean; onSelect: (e: MouseEvent) => void }> = {},
|
|
) {
|
|
return mount(BookCoverCard, {
|
|
props: { book: makeBook(), selectionMode: false, selected: false, ...props },
|
|
global: {
|
|
stubs: {
|
|
DropdownMenu: { template: '<div data-testid="dropdown-menu"><slot /></div>' },
|
|
DropdownMenuTrigger: { template: '<div data-testid="dropdown-trigger"><slot /></div>' },
|
|
DropdownMenuContent: { template: '<div data-testid="dropdown-content"><slot /></div>' },
|
|
DropdownMenuItem: { template: '<div data-testid="dropdown-item" @click="$emit(\'click\')"><slot /></div>', emits: ['click'] },
|
|
DropdownMenuSeparator: true,
|
|
DropdownMenuSub: { template: '<div data-testid="dropdown-sub"><slot /></div>' },
|
|
DropdownMenuSubTrigger: { template: '<div><slot /></div>' },
|
|
DropdownMenuSubContent: { template: '<div><slot /></div>' },
|
|
Tooltip: { template: '<div><slot /></div>' },
|
|
TooltipTrigger: { template: '<div><slot /></div>' },
|
|
TooltipContent: { template: '<div><slot /></div>' },
|
|
Loader2: true,
|
|
MoreVertical: true,
|
|
PanelRight: true,
|
|
BookOpen: true,
|
|
Play: true,
|
|
Check: true,
|
|
ExternalLink: true,
|
|
Pencil: true,
|
|
RefreshCw: true,
|
|
FolderPlus: true,
|
|
Send: true,
|
|
Trash2: true,
|
|
TriangleAlert: true,
|
|
Star: true,
|
|
Download: true,
|
|
Image: true,
|
|
Lock: true,
|
|
LockOpen: true,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
function setTouchMode(value: boolean) {
|
|
vi.stubGlobal('matchMedia', vi.fn<() => { matches: boolean }>().mockReturnValue({ matches: value }))
|
|
}
|
|
|
|
// -- tests -------------------------------------------------------------------
|
|
|
|
describe('BookCoverCard', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockRefreshing.value = false
|
|
mockCardOverlays.value = []
|
|
mockBookCoverDisplayMode.value = 'blurred-fit'
|
|
mockGridCardPrimaryLabel.value = 'hidden'
|
|
mockGridCardSecondaryLabel.value = 'hidden'
|
|
mockCardInfoMode.value = 'hover-overlay'
|
|
mockThumbnailClickAction.value = 'reader'
|
|
mockFetchAuthors.mockResolvedValue({
|
|
items: [{ id: 7, name: 'Frank Herbert', sortName: null, bookCount: 1, lastAddedAt: '2024-01-01T00:00:00Z' }],
|
|
total: 1,
|
|
page: 0,
|
|
size: 5,
|
|
})
|
|
setTouchMode(false)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
// ── hover overlay pointer-events (the bug fix) ────────────────────────────
|
|
|
|
describe('hover overlay pointer events', () => {
|
|
it('has pointer-events-none by default so the overlay does not block content below', () => {
|
|
const wrapper = mountCard()
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
expect(overlay.classes()).toContain('pointer-events-none')
|
|
})
|
|
|
|
it('has group-hover:pointer-events-auto so buttons become clickable on desktop hover', () => {
|
|
const wrapper = mountCard()
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
expect(overlay.classes()).toContain('group-hover:pointer-events-auto')
|
|
})
|
|
|
|
it('has group-hover:opacity-100 to become visible on desktop hover', () => {
|
|
const wrapper = mountCard()
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
expect(overlay.classes()).toContain('group-hover:opacity-100')
|
|
})
|
|
|
|
it('removes pointer-events-none and opacity-0 when mobile overlay is active', async () => {
|
|
setTouchMode(true)
|
|
const wrapper = mountCard()
|
|
await wrapper.find('.group').trigger('click')
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
expect(overlay.classes()).toContain('opacity-100')
|
|
expect(overlay.classes()).not.toContain('pointer-events-none')
|
|
})
|
|
|
|
it('does not apply group-hover classes when mobile overlay is active', async () => {
|
|
setTouchMode(true)
|
|
const wrapper = mountCard()
|
|
await wrapper.find('.group').trigger('click')
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
expect(overlay.classes()).not.toContain('group-hover:opacity-100')
|
|
expect(overlay.classes()).not.toContain('group-hover:pointer-events-auto')
|
|
})
|
|
|
|
it('is absent in selection mode (no hover overlay rendered)', () => {
|
|
const wrapper = mountCard({ selectionMode: true })
|
|
// The v-if="!selectionMode" removes the overlay entirely
|
|
expect(wrapper.find('.bg-black\\/70').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── quick view button ─────────────────────────────────────────────────────
|
|
|
|
describe('quick view button', () => {
|
|
it('emits action:quick-view when quick view button is clicked', async () => {
|
|
const wrapper = mountCard()
|
|
const overlayBtns = wrapper.find('.bg-black\\/70').findAll('button')
|
|
await overlayBtns[0]?.trigger('click')
|
|
expect(wrapper.emitted('action')?.[0]).toEqual(['quick-view'])
|
|
})
|
|
})
|
|
|
|
// ── card click: desktop vs mobile ────────────────────────────────────────
|
|
|
|
describe('card click behavior', () => {
|
|
it('opens primary file on desktop click when primaryFile exists', async () => {
|
|
setTouchMode(false)
|
|
const book = makeBook({ files: [makeFile({ id: 10, format: 'epub', role: 'primary' })] })
|
|
const wrapper = mountCard({ book })
|
|
await wrapper.find('.group').trigger('click')
|
|
expect(mockRouterPush).toHaveBeenCalledWith(expect.objectContaining({ name: 'reader', params: { bookId: 1, fileId: 10 } }))
|
|
})
|
|
|
|
it('opens book details on desktop click when thumbnail clicks prefer details', async () => {
|
|
setTouchMode(false)
|
|
mockThumbnailClickAction.value = 'details'
|
|
const book = makeBook({ id: 42, files: [makeFile({ id: 10, format: 'epub', role: 'primary' })] })
|
|
const wrapper = mountCard({ book })
|
|
|
|
await wrapper.find('.group').trigger('click')
|
|
|
|
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 42 } })
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
|
})
|
|
|
|
it('opens book details for missing books when thumbnail clicks prefer details', async () => {
|
|
setTouchMode(false)
|
|
mockThumbnailClickAction.value = 'details'
|
|
const book = makeBook({ id: 43, status: 'missing', files: [] })
|
|
const wrapper = mountCard({ book })
|
|
|
|
await wrapper.find('.group').trigger('click')
|
|
|
|
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 43 } })
|
|
})
|
|
|
|
it('does nothing on desktop click when no readable primary file exists', async () => {
|
|
setTouchMode(false)
|
|
const book = makeBook({ files: [] })
|
|
const wrapper = mountCard({ book })
|
|
await wrapper.find('.group').trigger('click')
|
|
expect(mockRouterPush).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not open file when clicking a button (event target is button)', async () => {
|
|
setTouchMode(false)
|
|
const wrapper = mountCard()
|
|
const btn = wrapper.find('button')
|
|
await btn.trigger('click')
|
|
// The card click handler returns early for button clicks
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
|
})
|
|
|
|
it('toggles mobile overlay on touch tap instead of opening file', async () => {
|
|
setTouchMode(true)
|
|
const wrapper = mountCard()
|
|
await wrapper.find('.group').trigger('click')
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
expect(overlay.classes()).toContain('opacity-100')
|
|
})
|
|
|
|
it('toggling mobile overlay off on second tap hides overlay', async () => {
|
|
setTouchMode(true)
|
|
const wrapper = mountCard()
|
|
await wrapper.find('.group').trigger('click')
|
|
await wrapper.find('.group').trigger('click')
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
expect(overlay.classes()).toContain('opacity-0')
|
|
})
|
|
|
|
it('emits select and does not open file in selection mode', async () => {
|
|
mockThumbnailClickAction.value = 'details'
|
|
const mockSelect = vi.fn<(e: MouseEvent) => void>()
|
|
const wrapper = mountCard({ selectionMode: true, onSelect: mockSelect })
|
|
await wrapper.find('.group').trigger('click')
|
|
expect(mockSelect).toHaveBeenCalled()
|
|
expect(mockRouterPush).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not open file for missing books on desktop', async () => {
|
|
setTouchMode(false)
|
|
const book = makeBook({ status: 'missing' })
|
|
const wrapper = mountCard({ book })
|
|
await wrapper.find('.group').trigger('click')
|
|
expect(mockRouterPush).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the explicit read button opening the reader when thumbnail clicks prefer details', async () => {
|
|
setTouchMode(false)
|
|
mockThumbnailClickAction.value = 'details'
|
|
const book = makeBook({ id: 44, files: [makeFile({ id: 12, format: 'epub', role: 'primary' })] })
|
|
const wrapper = mountCard({ book })
|
|
|
|
const overlayButtons = wrapper.find('.bg-black\\/70').findAll('button')
|
|
await overlayButtons[1]!.trigger('click')
|
|
|
|
expect(mockRouterPush).toHaveBeenCalledWith(expect.objectContaining({ name: 'reader', params: { bookId: 44, fileId: 12 } }))
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 44 } })
|
|
})
|
|
})
|
|
|
|
describe('below-cover label click behavior', () => {
|
|
it('opens book details when the primary label text is clicked', async () => {
|
|
mockCardInfoMode.value = 'below-cover'
|
|
mockGridCardPrimaryLabel.value = 'book-title'
|
|
const book = makeBook({ id: 42, files: [makeFile({ id: 10, format: 'epub', role: 'primary' })] })
|
|
const wrapper = mountCard({ book, showLabel: true })
|
|
|
|
await wrapper.get('[data-testid="grid-card-label-primary"]').trigger('click')
|
|
|
|
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 42 } })
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
|
})
|
|
|
|
it('opens author details when the author label text is clicked', async () => {
|
|
mockCardInfoMode.value = 'below-cover'
|
|
mockGridCardSecondaryLabel.value = 'author'
|
|
const book = makeBook({ id: 43, files: [makeFile({ id: 11, format: 'epub', role: 'primary' })] })
|
|
const wrapper = mountCard({ book, showLabel: true })
|
|
|
|
await wrapper.get('[data-testid="grid-card-label-secondary"]').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(mockFetchAuthors).toHaveBeenCalledWith({ q: 'Frank Herbert', page: 0, size: 5, sort: 'name', order: 'asc' })
|
|
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'author-detail', params: { id: 7 }, query: { from: '/' } })
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
|
})
|
|
|
|
it('opens series details when the series label text is clicked', async () => {
|
|
mockCardInfoMode.value = 'below-cover'
|
|
mockGridCardPrimaryLabel.value = 'series-title-position'
|
|
const book = makeBook({
|
|
id: 44,
|
|
seriesId: 42,
|
|
seriesName: 'Dune Chronicles',
|
|
seriesIndex: '1',
|
|
files: [makeFile({ id: 12, format: 'epub', role: 'primary' })],
|
|
})
|
|
const wrapper = mountCard({ book, showLabel: true })
|
|
|
|
await wrapper.get('[data-testid="grid-card-label-primary"]').trigger('click')
|
|
|
|
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'series-detail', params: { seriesId: 42 }, query: { from: '/' } })
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
|
})
|
|
})
|
|
|
|
// ── cover rendering ───────────────────────────────────────────────────────
|
|
|
|
describe('cover rendering', () => {
|
|
it('shows placeholder when hasCover is false', () => {
|
|
const wrapper = mountCard({ book: makeBook({ hasCover: false }) })
|
|
expect(wrapper.find('[data-testid="cover-placeholder"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('shows cover image when hasCover is true', () => {
|
|
const wrapper = mountCard({ book: makeBook({ hasCover: true }) })
|
|
// img element for the main cover image exists
|
|
expect(wrapper.find('img').exists()).toBe(true)
|
|
})
|
|
|
|
it('uses coverUrl composable for the image src', () => {
|
|
const book = makeBook({ id: 42, hasCover: true })
|
|
mountCard({ book })
|
|
expect(mockCoverUrl).toHaveBeenCalledWith(42, 'thumbnail', book.coverVersion)
|
|
})
|
|
|
|
it('constrains overlays to the natural fitted cover frame after image load', async () => {
|
|
mockBookCoverDisplayMode.value = 'natural-bottom'
|
|
const wrapper = mountCard({ book: makeBook({ hasCover: true }) })
|
|
const image = wrapper.find('img[alt="Dune"]')
|
|
Object.defineProperty(image.element, 'naturalWidth', { configurable: true, value: 1200 })
|
|
Object.defineProperty(image.element, 'naturalHeight', { configurable: true, value: 600 })
|
|
|
|
await image.trigger('load')
|
|
|
|
const frame = wrapper.find('[data-testid="cover-overlay-frame"]')
|
|
expect(frame.attributes('style')).toContain('height:')
|
|
expect(frame.attributes('style')).toContain('bottom: 0')
|
|
expect(frame.attributes('style')).not.toContain('top: 50%')
|
|
})
|
|
|
|
it('shows missing badge when book status is missing', () => {
|
|
const book = makeBook({ status: 'missing' })
|
|
const wrapper = mountCard({ book })
|
|
expect(wrapper.text()).toContain('Missing')
|
|
})
|
|
})
|
|
|
|
// ── book metadata display ─────────────────────────────────────────────────
|
|
|
|
describe('text metadata', () => {
|
|
it('shows book title', () => {
|
|
const wrapper = mountCard({ book: makeBook({ title: 'Foundation' }) })
|
|
expect(wrapper.text()).toContain('Foundation')
|
|
})
|
|
|
|
it('does not render hover title when no cover is loaded yet', () => {
|
|
// hasCover true but image not loaded - placeholder not shown, hover title hidden
|
|
const book = makeBook({ hasCover: true, title: 'Foundation' })
|
|
const wrapper = mountCard({ book })
|
|
// coverLoaded defaults to false; showHoverText = false → title not in overlay p tag
|
|
const overlay = wrapper.find('.bg-black\\/70')
|
|
const pTags = overlay.findAll('p')
|
|
// p with text is only rendered when showHoverText is true
|
|
expect(pTags.filter((p) => p.text().includes('Foundation')).length).toBe(0)
|
|
})
|
|
|
|
it('shows author in hover overlay when no cover (placeholder shown)', () => {
|
|
const book = makeBook({ hasCover: false, authors: ['Frank Herbert'] })
|
|
const wrapper = mountCard({ book })
|
|
expect(wrapper.find('.bg-black\\/70').text()).toContain('Frank Herbert')
|
|
})
|
|
})
|
|
|
|
// ── selection mode UI ─────────────────────────────────────────────────────
|
|
|
|
describe('selection mode', () => {
|
|
it('renders selection checkbox overlay in selection mode', () => {
|
|
const wrapper = mountCard({ selectionMode: true })
|
|
// There is a selection overlay div (absolute inset-0 z-30)
|
|
const selectionOverlay = wrapper.find('.z-30')
|
|
expect(selectionOverlay.exists()).toBe(true)
|
|
})
|
|
|
|
it('shows checkmark when selected', () => {
|
|
const wrapper = mountCard({ selectionMode: true, selected: true })
|
|
// Check that the selection overlay has primary styling
|
|
const selectionOverlay = wrapper.find('.z-30')
|
|
expect(selectionOverlay.classes()).toContain('bg-primary/20')
|
|
})
|
|
|
|
it('does not show check in unselected state', () => {
|
|
const wrapper = mountCard({ selectionMode: true, selected: false })
|
|
const selectionOverlay = wrapper.find('.z-30')
|
|
expect(selectionOverlay.classes()).not.toContain('bg-primary/20')
|
|
})
|
|
})
|
|
|
|
// ── author browse navigation ──────────────────────────────────────────────
|
|
|
|
describe('author browse', () => {
|
|
it('navigates to authors page when author button clicked', async () => {
|
|
const book = makeBook({ hasCover: false, authors: ['Frank Herbert'] })
|
|
const wrapper = mountCard({ book })
|
|
const authorBtn = wrapper.findAll('.bg-black\\/70 button').find((b) => b.text().includes('Frank Herbert'))
|
|
expect(authorBtn).toBeDefined()
|
|
await authorBtn!.trigger('click')
|
|
expect(mockRouterPush).toHaveBeenCalledWith({ name: 'authors', query: { q: 'Frank Herbert' } })
|
|
})
|
|
|
|
it('does not navigate when authors array is empty', async () => {
|
|
const book = makeBook({ hasCover: false, authors: [] })
|
|
const wrapper = mountCard({ book })
|
|
await wrapper.find('.group').trigger('click')
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'authors' }))
|
|
})
|
|
})
|
|
|
|
// ── refreshing spinner ────────────────────────────────────────────────────
|
|
|
|
describe('refresh spinner', () => {
|
|
it('renders refresh overlay when any refresh is active', async () => {
|
|
mockRefreshing.value = true
|
|
const wrapper = mountCard()
|
|
// The refresh overlay is the absolute z-40 div
|
|
const refreshOverlay = wrapper.find('.z-40')
|
|
expect(refreshOverlay.exists()).toBe(true)
|
|
})
|
|
|
|
it('does not render refresh overlay by default', () => {
|
|
mockRefreshing.value = false
|
|
mockIsRefreshing.mockReturnValue(false)
|
|
const wrapper = mountCard()
|
|
expect(wrapper.find('.z-40').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── dropdown menu actions ─────────────────────────────────────────────────
|
|
|
|
describe('dropdown menu', () => {
|
|
it('renders the kebab menu trigger button', () => {
|
|
const wrapper = mountCard()
|
|
// The dropdown trigger wraps a button with MoreVertical icon
|
|
expect(wrapper.find('[data-testid="dropdown-trigger"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('does not render dropdown in selection mode', () => {
|
|
const wrapper = mountCard({ selectionMode: true })
|
|
expect(wrapper.find('[data-testid="dropdown-menu"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('downloads a pure multi-track audiobook as an audio ZIP', async () => {
|
|
const book = makeBook({
|
|
id: 42,
|
|
files: [makeFile({ id: 10, format: 'mp3', role: 'primary' }), makeFile({ id: 11, format: 'mp3', role: 'content' })],
|
|
})
|
|
const wrapper = mountCard({ book })
|
|
const downloadItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Download')
|
|
|
|
expect(downloadItem).toBeDefined()
|
|
await downloadItem!.trigger('click')
|
|
|
|
expect(mockExportBooks).toHaveBeenCalledWith([42], false, 'audio')
|
|
expect(mockDownloadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('downloads the audio entry in a mixed multi-track audiobook as a ZIP while keeping ebook files single-file', async () => {
|
|
const book = makeBook({
|
|
id: 77,
|
|
files: [
|
|
makeFile({ id: 20, format: 'mp3', role: 'primary' }),
|
|
makeFile({ id: 21, format: 'mp3', role: 'content' }),
|
|
makeFile({ id: 22, format: 'epub', role: 'content' }),
|
|
],
|
|
})
|
|
const wrapper = mountCard({ book })
|
|
const audioZipItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Audiobook (ZIP)')
|
|
|
|
expect(audioZipItem).toBeDefined()
|
|
await audioZipItem!.trigger('click')
|
|
expect(mockExportBooks).toHaveBeenCalledWith([77], false, 'audio')
|
|
expect(mockDownloadFile).not.toHaveBeenCalled()
|
|
|
|
mockExportBooks.mockClear()
|
|
mockDownloadFile.mockClear()
|
|
|
|
const epubItems = wrapper.findAll('[data-testid="dropdown-item"]').filter((item) => item.text().trim() === 'EPUB e-book')
|
|
expect(epubItems).toHaveLength(2)
|
|
await epubItems[1]!.trigger('click')
|
|
|
|
expect(mockDownloadFile).toHaveBeenCalledWith(22)
|
|
expect(mockExportBooks).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reports a failed cover regeneration and keeps the cached cover', async () => {
|
|
const fetchMock = vi.fn<() => Promise<{ ok: boolean }>>().mockResolvedValue({ ok: false })
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const wrapper = mountCard({ book: makeBook({ id: 42 }) })
|
|
const regenerate = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Regenerate Cover')
|
|
|
|
await regenerate!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('/api/v1/books/42/re-extract-cover', { method: 'POST' })
|
|
expect(mockToastError).toHaveBeenCalledWith('Could not regenerate the cover')
|
|
expect(mockBumpVersion).not.toHaveBeenCalled()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('refreshes the cover after a successful regeneration', async () => {
|
|
vi.stubGlobal('fetch', vi.fn<() => Promise<{ ok: boolean }>>().mockResolvedValue({ ok: true }))
|
|
const wrapper = mountCard({ book: makeBook({ id: 42 }) })
|
|
const regenerate = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Regenerate Cover')
|
|
|
|
await regenerate!.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(mockBumpVersion).toHaveBeenCalledWith(42)
|
|
expect(mockToastError).not.toHaveBeenCalled()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
})
|
|
|
|
// ── cover aspect ratio ────────────────────────────────────────────────────
|
|
|
|
describe('cover aspect ratio', () => {
|
|
it('applies cover aspect ratio style to the cover container', () => {
|
|
const wrapper = mountCard()
|
|
// The cover wrapper has style with aspectRatio
|
|
const coverWrapper = wrapper.find('[style*="aspect-ratio"]')
|
|
expect(coverWrapper.exists()).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ── reading progress bar ──────────────────────────────────────────────────
|
|
|
|
describe('reading progress bar', () => {
|
|
it.each([
|
|
{ percentage: 98, status: 'read' as const },
|
|
{ percentage: 99.99974, status: 'read' as const },
|
|
])('uses the completed color at $percentage percent when status is $status', ({ percentage, status }) => {
|
|
mockCardOverlays.value = ['progress-bar']
|
|
const wrapper = mountCard({
|
|
book: makeBook({
|
|
files: [makeFile({ format: 'mp3' })],
|
|
readingProgress: percentage,
|
|
readStatus: makeReadStatus(status),
|
|
}),
|
|
})
|
|
|
|
const progressBar = wrapper.get('[data-testid="reading-progress-bar"]')
|
|
expect(progressBar.classes()).toContain('bg-green-500/80')
|
|
expect(progressBar.classes()).not.toContain('bg-primary/70')
|
|
})
|
|
|
|
it('uses the in-progress color below 100 percent when status is not read', () => {
|
|
mockCardOverlays.value = ['progress-bar']
|
|
const wrapper = mountCard({
|
|
book: makeBook({
|
|
readingProgress: 99.99974,
|
|
readStatus: makeReadStatus('reading'),
|
|
}),
|
|
})
|
|
|
|
const progressBar = wrapper.get('[data-testid="reading-progress-bar"]')
|
|
expect(progressBar.classes()).toContain('bg-primary/70')
|
|
expect(progressBar.classes()).not.toContain('bg-green-500/80')
|
|
})
|
|
|
|
it('updates the color when the read status changes', async () => {
|
|
mockCardOverlays.value = ['progress-bar']
|
|
const book = makeBook({
|
|
readingProgress: 99.99974,
|
|
readStatus: makeReadStatus('reading'),
|
|
})
|
|
const wrapper = mountCard({ book })
|
|
|
|
await wrapper.setProps({ book: { ...book, readStatus: makeReadStatus('read') } })
|
|
|
|
const progressBar = wrapper.get('[data-testid="reading-progress-bar"]')
|
|
expect(progressBar.classes()).toContain('bg-green-500/80')
|
|
expect(progressBar.classes()).not.toContain('bg-primary/70')
|
|
})
|
|
})
|
|
|
|
// ── send dialog ───────────────────────────────────────────────────────────
|
|
|
|
describe('send email dialog', () => {
|
|
it('shows send dialog when showSendDialog is triggered', async () => {
|
|
mockHasPermission.mockReturnValue(true)
|
|
const wrapper = mountCard()
|
|
// The dialog is not shown initially
|
|
expect(wrapper.find('[data-testid="send-dialog"]').exists()).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── series position overlay ───────────────────────────────────────────────
|
|
|
|
describe('series position overlay', () => {
|
|
it('hides badge when series-position is not in cardOverlays', () => {
|
|
mockCardOverlays.value = []
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '3', seriesName: 'Dune' }) })
|
|
expect(wrapper.text()).not.toContain('#3')
|
|
})
|
|
|
|
it('shows #3 badge when seriesIndex is 3 and overlay is enabled', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '3', seriesName: 'Dune' }) })
|
|
expect(wrapper.text()).toContain('#3')
|
|
})
|
|
|
|
it('marks badge as a card-click blocker and disables hit-testing on hover', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '3', seriesName: 'Dune' }) })
|
|
const badge = wrapper.find('[data-card-click-blocker]')
|
|
expect(badge.exists()).toBe(true)
|
|
expect(badge.classes()).toContain('group-hover:pointer-events-none')
|
|
})
|
|
|
|
it('shows #1.5 badge when seriesIndex is 1.5', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '1.5', seriesName: 'Dune' }) })
|
|
expect(wrapper.text()).toContain('#1.5')
|
|
})
|
|
|
|
it('preserves trailing zeros in exact series labels', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '3.0', seriesName: 'Dune' }) })
|
|
expect(wrapper.text()).toContain('#3.0')
|
|
})
|
|
|
|
it('hides badge when seriesIndex is null even if overlay is enabled', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: null, seriesName: 'Dune' }) })
|
|
expect(wrapper.text()).not.toContain('#')
|
|
})
|
|
|
|
it('hides badge in selection mode', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '2', seriesName: 'Dune' }), selectionMode: true })
|
|
expect(wrapper.text()).not.toContain('#2')
|
|
})
|
|
|
|
it('hides badge for missing books', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '2', seriesName: 'Dune', status: 'missing' }) })
|
|
expect(wrapper.text()).not.toContain('#2')
|
|
})
|
|
|
|
it('tooltip content includes series name and number', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '3', seriesName: 'Sprawl' }) })
|
|
expect(wrapper.text()).toContain('Sprawl #3')
|
|
})
|
|
|
|
it('tooltip content shows only number when seriesName is null', () => {
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '3', seriesName: null }) })
|
|
expect(wrapper.text()).toContain('#3')
|
|
})
|
|
|
|
it('does not open the reader when the series badge is clicked', async () => {
|
|
setTouchMode(false)
|
|
mockCardOverlays.value = ['series-position']
|
|
const wrapper = mountCard({ book: makeBook({ seriesIndex: '3', seriesName: 'Dune' }) })
|
|
const badge = wrapper.find('[data-card-click-blocker]')
|
|
expect(badge.exists()).toBe(true)
|
|
await badge.trigger('click')
|
|
expect(mockRouterPush).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'reader' }))
|
|
})
|
|
})
|
|
})
|