diff --git a/client/src/features/book-requests/BookRequestsPage.vue b/client/src/features/book-requests/BookRequestsPage.vue index f92ea99b6..8f15a2a79 100644 --- a/client/src/features/book-requests/BookRequestsPage.vue +++ b/client/src/features/book-requests/BookRequestsPage.vue @@ -12,6 +12,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions' import { useAuth } from '@/features/auth/composables/useAuth' import { formatCompactNumber, formatNumber } from '@/i18n/formatters' import { canBulkActRequest, canDismissRequest, cancelStopsATransfer } from './requestActions' +import { flattenRequestRows, rowRequests } from './requestPair' import RequestDrawer from './components/RequestDrawer.vue' import NoRequestSourcesNotice from './components/NoRequestSourcesNotice.vue' import RequestEmptyState from './components/RequestEmptyState.vue' @@ -92,8 +93,10 @@ const isCompact = computed(() => density.value === 'compact') * holding the row objects would keep a stale copy of whatever the tick just changed. */ const selectedIds = ref([]) -const selectedRows = computed(() => activeList.value.items.value.filter((request) => selectedIds.value.includes(request.id))) -const selectableRows = computed(() => activeList.value.items.value.filter((request) => canBulkActRequest(request, canManage.value))) +/** Every request on the page with pairs unfolded, since a selection and a batch act on requests, not rows. */ +const activeRequests = computed(() => flattenRequestRows(activeList.value.items.value)) +const selectedRows = computed(() => activeRequests.value.filter((request) => selectedIds.value.includes(request.id))) +const selectableRows = computed(() => activeRequests.value.filter((request) => canBulkActRequest(request, canManage.value))) /** * A selection can mix states, so each bulk button owns its own subset and says how big it is. @@ -116,7 +119,8 @@ provide(REQUEST_DRAWER, { const STATUS_FILTERS: Array = ['', ...BOOK_REQUEST_STATUSES] -const confirming = ref<{ request: BookRequestItem; action: 'cancel' | 'delete' } | null>(null) +/** One request, or both halves of a pair cancelled from their row in one press. */ +const confirming = ref<{ requests: BookRequestItem[]; action: 'cancel' | 'delete' } | null>(null) /** Open while the approver is writing the one reason the whole selection is being turned down for. */ const rejectingSelection = ref(false) const rejectNote = ref('') @@ -128,14 +132,14 @@ const confirmTitle = computed(() => const confirmDescription = computed(() => { if (!confirming.value) return '' if (confirming.value.action === 'delete') return t('bookRequests.confirm.deleteDescription') - return cancelStopsATransfer(confirming.value.request) + return confirming.value.requests.some(cancelStopsATransfer) ? t('bookRequests.confirm.cancelDescriptionWithDownload') : t('bookRequests.confirm.cancelDescription') }) const confirmLabel = computed(() => (confirming.value?.action === 'delete' ? t('bookRequests.actions.delete') : t('bookRequests.actions.cancel'))) -/** The dialog acts on one row, so it is busy only while that row's own action is running. */ -const confirmBusy = computed(() => actions.isPending(confirming.value?.request.id)) +/** The dialog acts on one row, so it is busy only while that row's own actions are running. */ +const confirmBusy = computed(() => confirming.value?.requests.some((request) => actions.isPending(request.id)) ?? false) async function loadActive() { if (activeTab.value === 'mine') await mine.fetchRequests() @@ -176,7 +180,7 @@ watch(() => activeList.value.items.value, onRowsChanged) /** A refetch both trims the selection and retires the live ticks the fetched rows have overtaken. */ function onRowsChanged() { pruneSelection() - pruneSettledProgress(activeList.value.items.value) + pruneSettledProgress(activeRequests.value) } function selectSearchTab() { @@ -196,10 +200,13 @@ function clearSelection() { selectedIds.value = [] } -function toggleSelect(request: BookRequestItem) { - const index = selectedIds.value.indexOf(request.id) - if (index === -1) selectedIds.value.push(request.id) - else selectedIds.value.splice(index, 1) +/** A pair row's checkbox stands for every half of it a bulk action can reach. */ +function toggleSelect(row: BookRequestItem) { + const ids = rowRequests(row) + .filter((request) => canBulkActRequest(request, canManage.value)) + .map((request) => request.id) + const allSelected = ids.every((id) => selectedIds.value.includes(id)) + selectedIds.value = allSelected ? selectedIds.value.filter((id) => !ids.includes(id)) : [...new Set([...selectedIds.value, ...ids])] } /** Tick-all covers the rows that can actually be approved, not every row on the page. */ @@ -337,10 +344,67 @@ function handleReject(request: BookRequestItem) { /** Stopping a live transfer and deleting a row both ask first; the rest are one click. */ function handleCancel(request: BookRequestItem) { - if (cancelStopsATransfer(request)) confirming.value = { request, action: 'cancel' } + if (cancelStopsATransfer(request)) confirming.value = { requests: [request], action: 'cancel' } else void cancelRequest(request) } +/** + * The halves of a pair, acted on from their row. One half goes through the same path a single + * request does; two go through the batch endpoints, which report each half's own outcome. + */ +function handlePairApprove(requests: BookRequestItem[]) { + if (requests.length === 1) { + void approveRequest(requests[0]!) + return + } + void runBulk( + requests, + actions.approveMany, + { failed: 'bookRequests.errors.bulkApproveFailed', done: 'bookRequests.toasts.pairApproved', partial: 'bookRequests.toasts.bulkApprovedPartial' }, + false, + ) +} + +function handlePairReject(requests: BookRequestItem[]) { + if (requests.length === 1) { + void rejectRequest(requests[0]!) + return + } + void runBulk( + requests, + (ids) => actions.rejectMany(ids), + { failed: 'bookRequests.errors.bulkRejectFailed', done: 'bookRequests.toasts.pairRejected', partial: 'bookRequests.toasts.bulkRejectedPartial' }, + false, + ) +} + +function handlePairCancel(requests: BookRequestItem[]) { + if (requests.some(cancelStopsATransfer)) confirming.value = { requests, action: 'cancel' } + else void cancelRequests(requests) +} + +function handlePairDismiss(requests: BookRequestItem[]) { + void runBulk( + requests, + actions.dismissMany, + { + failed: 'bookRequests.errors.bulkDismissFailed', + done: 'bookRequests.toasts.bulkDismissed', + partial: 'bookRequests.toasts.bulkDismissedPartial', + }, + !activeList.value.includeDismissed.value, + ) +} + +function handlePairRestore(requests: BookRequestItem[]) { + for (const request of requests) void restoreRequest(request) +} + +/** One at a time, each with its own outcome, so one half refusing does not hide the other succeeding. */ +async function cancelRequests(requests: BookRequestItem[]) { + for (const request of requests) await cancelRequest(request) +} + function handleDismiss(request: BookRequestItem) { void dismissRequest(request) } @@ -350,15 +414,16 @@ function handleRestore(request: BookRequestItem) { } function handleRemove(request: BookRequestItem) { - confirming.value = { request, action: 'delete' } + confirming.value = { requests: [request], action: 'delete' } } /** Open until the action settles, so `busy` has something to render and a second Enter does nothing. */ async function handleConfirm() { const pending = confirming.value if (!pending) return - if (pending.action === 'delete') await deleteRequest(pending.request) - else await cancelRequest(pending.request) + if (pending.action === 'delete') { + for (const request of pending.requests) await deleteRequest(request) + } else await cancelRequests(pending.requests) confirming.value = null } @@ -736,6 +801,11 @@ function goToNextPage() { @restore="handleRestore" @remove="handleRemove" @grab="openReleases" + @pair-approve="handlePairApprove" + @pair-reject="handlePairReject" + @pair-cancel="handlePairCancel" + @pair-dismiss="handlePairDismiss" + @pair-restore="handlePairRestore" @sort="handleSort" @toggle-select="toggleSelect" @toggle-select-all="toggleSelectAll" diff --git a/client/src/features/book-requests/__tests__/BookMissingFormatRequest.spec.ts b/client/src/features/book-requests/__tests__/BookMissingFormatRequest.spec.ts new file mode 100644 index 000000000..aaef6666a --- /dev/null +++ b/client/src/features/book-requests/__tests__/BookMissingFormatRequest.spec.ts @@ -0,0 +1,120 @@ +import { flushPromises, mount, RouterLinkStub } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { computed } from 'vue' +import type { BookDetail } from '@bookorbit/types' + +const state = vi.hoisted(() => ({ + api: vi.fn<(path: string, init?: RequestInit) => Promise>(), + hasPermission: vi.fn<(permission: string) => boolean>(), + showBookRequests: true, + toastSuccess: vi.fn<(message: string) => void>(), + toastError: vi.fn<(message: string) => void>(), +})) + +vi.mock('@/lib/api', () => ({ api: state.api })) +vi.mock('vue-sonner', () => ({ toast: { success: state.toastSuccess, error: state.toastError } })) +vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission: state.hasPermission }) })) +vi.mock('../composables/useBookRequestVisibility', () => ({ + useBookRequestVisibility: () => ({ showBookRequests: computed(() => state.showBookRequests) }), +})) + +import BookMissingFormatRequest from '../components/BookMissingFormatRequest.vue' + +function book(formats: string[]): BookDetail { + return { + id: 42, + title: 'Dune', + authors: [{ id: 1, name: 'Frank Herbert', sortName: null }], + isbn10: null, + isbn13: '9780441013593', + seriesName: null, + publishedYear: 1965, + language: 'en', + files: formats.map((format, index) => ({ id: index + 1, format, role: 'content', filename: `dune.${format}` })), + } as unknown as BookDetail +} + +function json(body: unknown, ok = true): Response { + return { ok, json: async () => body } as Response +} + +const NOTHING = { ownedBookId: null, existingRequestId: null, existingRequestStatus: null, alreadySubscribed: false } + +async function render(formats: string[]) { + const wrapper = mount(BookMissingFormatRequest, { props: { book: book(formats) }, global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + return wrapper +} + +describe('BookMissingFormatRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + state.showBookRequests = true + state.hasPermission.mockReturnValue(true) + state.api.mockResolvedValue(json([NOTHING])) + }) + + it('offers the audiobook an ebook is missing, and asks whether anybody has it already', async () => { + const wrapper = await render(['epub']) + + expect(wrapper.text()).toContain('No audiobook yet') + const [path, init] = state.api.mock.calls[0]! + expect(path).toBe('/api/v1/book-requests/availability') + expect(JSON.parse(init!.body as string).items[0]).toMatchObject({ title: 'Dune', mediaKind: 'audiobook', isbn13: '9780441013593' }) + }) + + it('offers the ebook an audiobook is missing', async () => { + const wrapper = await render(['m4b']) + + expect(wrapper.text()).toContain('No e-book yet') + expect(wrapper.get('button').attributes('aria-label')).toBe('Request e-book') + }) + + it('asks for the format against this book, so it joins it rather than becoming another', async () => { + const wrapper = await render(['epub']) + state.api.mockResolvedValueOnce(json({ request: { id: 77, status: 'pending' }, subscribed: false })) + + await wrapper.get('button').trigger('click') + await flushPromises() + + const [path, init] = state.api.mock.calls[1]! + expect(path).toBe('/api/v1/book-requests') + expect(JSON.parse(init!.body as string)).toMatchObject({ mediaKind: 'audiobook', targetBookId: 42, title: 'Dune', language: 'en' }) + expect(state.toastSuccess).toHaveBeenCalledWith('Requested. The audiobook will join this book when it arrives') + expect(wrapper.text()).toContain('Requested') + expect(wrapper.findComponent(RouterLinkStub).props('to')).toMatchObject({ name: 'book-request-detail', params: { id: 77 } }) + }) + + it('says the translated reason when the server refuses', async () => { + const wrapper = await render(['epub']) + state.api.mockResolvedValueOnce(json({ errorCode: 'SUBMIT_TARGET_BOOK_HAS_MEDIUM', message: 'That book already has a audiobook' }, false)) + + await wrapper.get('button').trigger('click') + await flushPromises() + + expect(state.toastError).toHaveBeenCalledWith('Could not submit that request', { description: 'That book already has this format' }) + }) + + it('offers to join a request somebody else already made', async () => { + state.api.mockResolvedValue(json([{ ...NOTHING, existingRequestId: 9, existingRequestStatus: 'pending' }])) + const wrapper = await render(['epub']) + + expect(wrapper.text()).toContain('Requested by someone else') + expect(wrapper.get('button').text()).toBe('Add me too') + }) + + it.each([ + ['a book holding both formats', ['epub', 'm4b'], true, true], + ['a comic', ['cbz'], true, true], + ['somebody without request access', ['epub'], false, true], + ['somebody who hid requests', ['epub'], true, false], + ])('stays out of the way for %s', async (_label, formats, permitted, visible) => { + state.hasPermission.mockReturnValue(permitted) + state.showBookRequests = visible + + const wrapper = await render(formats) + + expect(wrapper.find('[role="group"]').exists()).toBe(false) + expect(state.api).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts b/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts index eb738ead0..08a21e844 100644 --- a/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts @@ -1,4 +1,5 @@ import { flushPromises, mount, RouterLinkStub } from '@vue/test-utils' +import { nextTick } from 'vue' import type { DOMWrapper, VueWrapper } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Permission } from '@bookorbit/types' @@ -13,12 +14,22 @@ const hasPermission = vi.fn<(permission: string) => boolean>() vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission: (permission: string) => hasPermission(permission) }), })) -vi.mock('vue-router', () => ({ - useRoute: () => ({ params: { id: '7' }, query: {} }), - useRouter: () => ({ push: vi.fn<() => void>() }), +// A route that can move, so a pair's tabs and the hop after a send can be followed. Pushing to a +// release picker moves it the way the router would; every other destination is only recorded. +const nav = vi.hoisted(() => ({ + route: null as unknown as { params: { id: string }; query: Record }, + push: vi.fn<(to: { name: string; params?: { id: number } }) => void>(), })) +vi.mock('vue-router', async () => { + const { reactive } = await import('vue') + nav.route = reactive({ params: { id: '7' }, query: {} }) + return { useRoute: () => nav.route, useRouter: () => ({ push: nav.push }) } +}) +// The real composable builds the router at module scope. +vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: { value: { id: 1 } } }) })) import ReleasePickerPanel from '../components/ReleasePickerPanel.vue' +import { clearReleaseSearchCache } from '../composables/useReleasePicker' function request(overrides: Partial = {}): BookRequestItem { return { @@ -47,6 +58,8 @@ function request(overrides: Partial = {}): BookRequestItem { subscribers: [], download: null, dismissed: false, + companionRequestId: null, + targetBookId: null, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z', title: 'Spark of the Everflame', @@ -195,6 +208,13 @@ beforeEach(() => { api.mockReset() // A moderator by default, which is the path every existing case in this file was written against. hasPermission.mockReturnValue(true) + clearReleaseSearchCache() + nav.push.mockReset() + nav.push.mockImplementation((to) => { + if (to.name === 'book-request-releases' && to.params) nav.route.params.id = String(to.params.id) + }) + nav.route.params.id = '7' + nav.route.query = {} }) describe('ReleasePickerPanel', () => { @@ -863,3 +883,116 @@ describe('ReleasePickerPanel', () => { }) }) }) + +describe('ReleasePickerPanel for a pair', () => { + const ebookHalf = (overrides: Partial = {}) => request({ id: 7, companionRequestId: 8, ...overrides }) + const audioHalf = (overrides: Partial = {}) => request({ id: 8, mediaKind: 'audiobook', companionRequestId: 7, ...overrides }) + const AUDIO_SEARCH = { + ...MIXED, + releases: [release({ guid: 'audio-1', format: 'm4b', formats: ['m4b'] })], + criteria: { ...MIXED.criteria, mediaKind: 'audiobook' }, + indexers: [status()], + } + + async function renderPair(audio: BookRequestItem = audioHalf()) { + api.mockImplementation((input: string) => { + if (input.includes('/releases/inspect')) return respond(READY_INSPECTION) + if (input.includes('/7/releases')) return respond(MIXED) + if (input.includes('/8/releases')) return respond(AUDIO_SEARCH) + if (input.includes('/7/grab')) return respond(ebookHalf({ status: 'grabbed' })) + if (input.includes('download-clients')) return respond([]) + if (input.endsWith('/book-requests/8')) return respond(audio) + return respond(ebookHalf()) + }) + const wrapper = mount(ReleasePickerPanel, { global: { stubs: { RouterLink: RouterLinkStub } } }) + await flushPromises() + return wrapper + } + + const tabs = (wrapper: VueWrapper) => wrapper.get('nav[aria-label="Formats to pick releases for"]') + const searchesFor = (id: number) => api.mock.calls.filter(([input]) => input.endsWith(`/${id}/releases`)).length + + async function sendFirstRelease(wrapper: VueWrapper) { + const send = releaseRows(wrapper)[0]! + .findAll('button') + .find((button) => /Download release|Send/.test(button.text()))! + await send.trigger('click') + await flushPromises() + } + + it('tabs between the halves, each with its release count and what it still needs', async () => { + const wrapper = await renderPair() + + expect(wrapper.text()).toContain('Pick releases') + const [ebook, audiobook] = tabs(wrapper).findAll('a') + expect(ebook!.text()).toContain('E-book') + expect(ebook!.text()).toContain('2 releases') + expect(ebook!.text()).toContain('To choose') + expect(audiobook!.text()).toContain('Audiobook') + expect(audiobook!.text()).toContain('1 release') + expect(audiobook!.text()).toContain('To choose') + }) + + it('searches the other half once, as the picker opens, and switches to it without a loading state', async () => { + const wrapper = await renderPair() + expect(searchesFor(8)).toBe(1) + + nav.route.params.id = '8' + await nextTick() + + expect(releaseRows(wrapper)).toHaveLength(1) + expect(wrapper.text()).not.toContain('Searching') + await flushPromises() + expect(searchesFor(8)).toBe(1) + expect(searchesFor(7)).toBe(1) + }) + + it('moves to the other half once a release is sent for this one', async () => { + const wrapper = await renderPair() + + await sendFirstRelease(wrapper) + + expect(nav.push).toHaveBeenCalledWith({ name: 'book-request-releases', params: { id: 8 }, query: {} }) + }) + + it('ends on a closing state once neither half needs a release', async () => { + const wrapper = await renderPair(audioHalf({ status: 'downloading' })) + + await sendFirstRelease(wrapper) + + expect(wrapper.text()).toContain('Both releases are on their way') + expect(releaseRows(wrapper)).toHaveLength(0) + expect(tabs(wrapper).text().match(/Sent/g)).toHaveLength(2) + await wrapper + .findAll('button') + .find((button) => button.text() === 'Done')! + .trigger('click') + expect(nav.push).toHaveBeenLastCalledWith({ name: 'book-requests', query: {} }) + }) + + it('returns to the request when the other half is not waiting on a release', async () => { + const wrapper = await renderPair(audioHalf({ status: 'pending' })) + + await sendFirstRelease(wrapper) + + expect(nav.push).toHaveBeenCalledWith({ name: 'book-request-detail', params: { id: 7 }, query: {} }) + expect(wrapper.text()).not.toContain('Both releases are on their way') + }) + + it('shows a half nobody can pick a release for yet as a label, not a link, and never searches it', async () => { + const wrapper = await renderPair(audioHalf({ status: 'pending' })) + + const audiobook = tabs(wrapper).get('span[aria-disabled="true"]') + expect(audiobook.text()).toContain('Pending') + expect(searchesFor(8)).toBe(0) + }) + + it('leaves the ISBN one half was opened with out of the link to the other', async () => { + nav.route.query = { tab: 'mine', isbn: '9781234567897' } + const wrapper = await renderPair() + + const links = wrapper.findAllComponents(RouterLinkStub) + const toAudiobook = links.find((link) => (link.props('to') as { params?: { id: number } }).params?.id === 8)! + expect(toAudiobook.props('to')).toEqual({ name: 'book-request-releases', params: { id: 8 }, query: { tab: 'mine' } }) + }) +}) diff --git a/client/src/features/book-requests/__tests__/RequestListTable.spec.ts b/client/src/features/book-requests/__tests__/RequestListTable.spec.ts index 4ffc95e6b..373acaceb 100644 --- a/client/src/features/book-requests/__tests__/RequestListTable.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestListTable.spec.ts @@ -46,6 +46,8 @@ function request(overrides: Partial = {}): BookRequestItem { subscribers: [], download: null, dismissed: false, + companionRequestId: null, + targetBookId: null, createdAt: '2026-09-29T12:00:00.000Z', updatedAt: '2026-09-29T12:00:00.000Z', ...overrides, @@ -105,3 +107,64 @@ describe('RequestListTable', () => { expect(titleButton?.text()).toBe(LONG_TITLE) }) }) + +describe('RequestListTable pairs', () => { + const ebook = request({ id: 10, mediaKind: 'ebook', status: 'pending', title: 'Dune', companionRequestId: 11, matchedBookId: null }) + const audiobook = request({ id: 11, mediaKind: 'audiobook', status: 'downloading', title: 'Dune', companionRequestId: 10, matchedBookId: null }) + + function renderPair(selectedIds: number[] = []) { + return mount(RequestListTable, { + props: { + requests: [{ ...ebook, companion: audiobook }], + canManage: true, + canSelfFulfil: false, + currentUserId: 1, + busyIds: new Set(), + progressByRequest: {}, + density: 'comfortable', + sortBy: 'createdAt', + sortDir: 'desc', + selectedIds, + }, + global: { + stubs: { + RouterLink: RouterLinkStub, + RequestCover: true, + RequestPipeline: true, + RequestStatusBadge: { props: ['status'], template: '{{ status }}' }, + RequestPairRowActions: { + props: ['row'], + emits: ['approve'], + template: '', + }, + }, + }, + }) + } + + it('renders the pair as one row with a lane per half, the ebook first', () => { + const wrapper = renderPair() + + expect(wrapper.findAll('tbody tr')).toHaveLength(1) + const lanes = wrapper + .get('table') + .findAll('.status-badge') + .map((badge) => badge.text()) + expect(lanes).toEqual(['pending', 'downloading']) + expect(wrapper.find('table').text()).toContain('E-book and audiobook') + }) + + it('hands a row press on to the page with exactly the halves it applies to', async () => { + const wrapper = renderPair() + + await wrapper.get('table .pair-approve').trigger('click') + + expect(wrapper.emitted('pair-approve')?.[0]?.[0]).toEqual([expect.objectContaining({ id: 10 }), expect.objectContaining({ id: 11 })]) + }) + + it('ticks the row only when every half a bulk action can reach is selected', () => { + // Only the pending ebook can be acted on in bulk; the downloading audiobook cannot. + expect((renderPair([10]).get('table tbody input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true) + expect((renderPair([]).get('table tbody input[type="checkbox"]').element as HTMLInputElement).checked).toBe(false) + }) +}) diff --git a/client/src/features/book-requests/__tests__/RequestPairRowActions.spec.ts b/client/src/features/book-requests/__tests__/RequestPairRowActions.spec.ts new file mode 100644 index 000000000..ba35049fa --- /dev/null +++ b/client/src/features/book-requests/__tests__/RequestPairRowActions.spec.ts @@ -0,0 +1,53 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import type { BookRequestItem } from '@bookorbit/types' +import RequestPairRowActions from '../components/RequestPairRowActions.vue' + +function request(overrides: Partial): BookRequestItem { + return { + id: 10, + userId: 4, + mediaKind: 'ebook', + status: 'approved', + dismissed: false, + download: null, + fulfillerUserId: null, + title: 'Dune', + ...overrides, + } as BookRequestItem +} + +function primaryButton(row: BookRequestItem) { + const wrapper = mount(RequestPairRowActions, { props: { row, canManage: true, canSelfFulfil: false, currentUserId: 4, busy: false } }) + return wrapper.findAll('button')[0]! +} + +describe('RequestPairRowActions', () => { + it('offers to choose releases while both halves are waiting on one', () => { + const button = primaryButton({ ...request({}), companion: request({ id: 11, mediaKind: 'audiobook' }) }) + + expect(button.text()).toBe('Choose releases') + }) + + it('names the one half still waiting once the other is on its way', () => { + const button = primaryButton({ ...request({ status: 'downloading' }), companion: request({ id: 11, mediaKind: 'audiobook' }) }) + + expect(button.text()).toBe('Find an audiobook release') + }) + + it('opens the picker on the half that leads the row', async () => { + const wrapper = mount(RequestPairRowActions, { + props: { + row: { ...request({}), companion: request({ id: 11, mediaKind: 'audiobook' }) }, + canManage: true, + canSelfFulfil: false, + currentUserId: 4, + busy: false, + }, + }) + + await wrapper.findAll('button')[0]!.trigger('click') + + expect(wrapper.emitted('grab')?.[0]?.[0]).toEqual(expect.objectContaining({ id: 10 })) + }) +}) diff --git a/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts b/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts index e0dd2e9ab..59ea3bdcd 100644 --- a/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts @@ -16,7 +16,10 @@ const state = vi.hoisted( hasPermission: vi.fn<(permission: string) => boolean>(), defaultFor: vi.fn<(mediaKind: BookRequestMediaKind) => ResolvedRequestDestination>(), submit: vi.fn<() => Promise>(), + submitPair: vi.fn<() => Promise>(), submitFreeText: vi.fn<() => Promise>(), + submitPairFreeText: vi.fn<() => Promise>(), + search: vi.fn<() => void>(), checkFreeText: vi.fn<() => Promise>(), push: vi.fn<() => Promise>(), routeQuery: {} as Record, @@ -25,12 +28,16 @@ const state = vi.hoisted( hasPermission: ReturnType defaultFor: ReturnType mediaKind: Ref - libraries: Ref<{ id: number; name: string; folders: { id: number; path: string }[] }[]> + pairMode: Ref + libraries: Ref<{ id: number; name: string; folders: { id: number; path: string }[]; organizationMode?: string }[]> hasSearched: Ref providers: Ref groups: Ref submit: ReturnType + submitPair: ReturnType submitFreeText: ReturnType + submitPairFreeText: ReturnType + search: ReturnType checkFreeText: ReturnType push: ReturnType routeQuery: Record @@ -81,7 +88,7 @@ vi.mock('@/features/book/composables/useMetadataSearch', async () => { hasSearched: state.hasSearched, providers: state.providers, loadProviders: vi.fn<() => Promise>().mockResolvedValue(undefined), - search: vi.fn<() => void>(), + search: state.search, }), } }) @@ -90,6 +97,7 @@ vi.mock('../composables/useRequestSubmission', async (importOriginal) => { const actual = await importOriginal() const { ref } = await import('vue') state.mediaKind = ref('ebook') + state.pairMode = ref(false) state.lastFailure = ref(null) return { ...actual, @@ -97,10 +105,13 @@ vi.mock('../composables/useRequestSubmission', async (importOriginal) => { lastFailure: state.lastFailure, submitting: ref(null), mediaKind: state.mediaKind, + pairMode: state.pairMode, annotate: vi.fn<() => void>(), getAvailability: vi.fn<() => null>().mockReturnValue(null), submit: state.submit, + submitPair: state.submitPair, submitFreeText: state.submitFreeText, + submitPairFreeText: state.submitPairFreeText, checkFreeText: state.checkFreeText, candidateKey: vi.fn<() => string>().mockReturnValue('k'), }), @@ -245,6 +256,8 @@ describe('RequestSearchPanel provider sources', () => { publishedYear: 2022, providers: [MetadataProviderKey.GOOGLE, MetadataProviderKey.AMAZON, MetadataProviderKey.COMICVINE], availability: null, + pairAvailability: null, + audioCoverUrls: [], }, ] }) @@ -811,3 +824,142 @@ describe('RequestSearchPanel free-text fallback', () => { expect(state.coverOrder?.value).toEqual(['audible', 'itunes']) }) }) + +describe('RequestSearchPanel pair mode', () => { + const dune: MetadataCandidate = { + provider: MetadataProviderKey.GOOGLE, + providerId: 'google-dune', + title: 'Dune', + authors: ['Frank Herbert'], + coverUrl: 'https://covers.test/portrait.jpg', + } + const OWNED = { ownedBookId: 42, existingRequestId: null, existingRequestStatus: null, alreadySubscribed: false } + + function group(pairAvailability: CandidateGroup['pairAvailability']): CandidateGroup { + return { + key: 'dune', + candidate: dune, + candidates: [dune], + isbns: [], + isbnChoices: [], + recommendedIsbnChoice: null, + title: 'Dune', + authors: ['Frank Herbert'], + coverUrl: 'https://covers.test/portrait.jpg', + coverUrls: ['https://covers.test/portrait.jpg'], + publishedYear: 1965, + providers: [MetadataProviderKey.GOOGLE], + availability: null, + pairAvailability, + audioCoverUrls: ['https://covers.test/square.jpg'], + } + } + + function button(wrapper: Awaited>, text: string) { + return wrapper.findAll('button').find((candidate) => candidate.text() === text) + } + + beforeEach(() => { + vi.clearAllMocks() + state.mediaKind.value = 'ebook' + state.pairMode.value = false + state.hasSearched.value = false + state.hasPermission.mockReturnValue(false) + state.defaultFor.mockReturnValue(NO_DEFAULT) + state.providers.value = [{ key: MetadataProviderKey.GOOGLE, label: 'Google Books', identifiable: true }] + state.libraries.value = [ + { id: 1, name: 'Books', folders: [{ id: 11, path: '/books' }], organizationMode: 'book_per_folder' }, + { id: 2, name: 'Audiobooks', folders: [{ id: 21, path: '/audiobooks' }], organizationMode: 'book_per_folder' }, + ] + }) + + it('offers Both beside the two media it combines, and searches both media at once', async () => { + state.hasSearched.value = true + const wrapper = await render() + await wrapper.get('#request-title').setValue('Dune') + + const choices = wrapper + .findAll('fieldset')[0]! + .findAll('button') + .map((choice) => choice.text()) + // Comics are never offered here; the server only keeps accepting them for released iOS builds. + expect(choices).toEqual(['E-book', 'Audiobook', 'Both']) + + await button(wrapper, 'Both')!.trigger('click') + + expect(state.pairMode.value).toBe(true) + expect(state.mediaKind.value).toBe('ebook') + expect(button(wrapper, 'Both')!.attributes('aria-pressed')).toBe('true') + expect(state.search).toHaveBeenLastCalledWith(expect.objectContaining({ mediaKinds: ['ebook', 'audiobook'] })) + expect(state.search).toHaveBeenLastCalledWith(expect.not.objectContaining({ mediaKind: expect.anything() })) + }) + + it('shows where each half stands and submits both, each to its own library', async () => { + state.pairMode.value = true + state.groups.value = [group({ ebook: null, audiobook: null })] + state.submitPair.mockResolvedValue(null) + const wrapper = await render() + + await wrapper.get('#request-library').setValue('1') + await wrapper.get('#request-audio-library').setValue('2') + expect(wrapper.text().match(/Not requested/g)).toHaveLength(2) + + await button(wrapper, 'Request both')!.trigger('click') + + expect(state.submitPair).toHaveBeenCalledWith( + dune, + expect.objectContaining({ + ebook: expect.objectContaining({ targetLibraryId: 1, coverUrl: 'https://covers.test/portrait.jpg' }), + audiobook: expect.objectContaining({ targetLibraryId: 2, coverUrl: 'https://covers.test/square.jpg' }), + }), + ) + expect(state.submit).not.toHaveBeenCalled() + }) + + it('asks only for the missing format, aimed at the copy already on the shelf', async () => { + state.pairMode.value = true + state.groups.value = [group({ ebook: OWNED, audiobook: null })] + state.submit.mockResolvedValue(null) + const wrapper = await render() + + expect(wrapper.text()).toContain('In your library') + await button(wrapper, 'Request audiobook')!.trigger('click') + + expect(state.submit).toHaveBeenCalledWith(dune, expect.objectContaining({ mediaKind: 'audiobook', targetBookId: 42 })) + expect(state.submitPair).not.toHaveBeenCalled() + }) + + it('says before submitting whether the pair will arrive as one book or two', async () => { + state.pairMode.value = true + state.groups.value = [group({ ebook: null, audiobook: null })] + const wrapper = await render() + + await wrapper.get('#request-library').setValue('1') + await wrapper.get('#request-audio-library').setValue('1') + expect(wrapper.text()).toContain('Both formats arrive as one book in Books.') + + await wrapper.get('#request-audio-library').setValue('2') + expect(wrapper.text()).toContain('they arrive as two books') + }) + + it('offers nothing to press once both formats are already in the library', async () => { + state.pairMode.value = true + state.groups.value = [group({ ebook: OWNED, audiobook: { ...OWNED, ownedBookId: 43 } })] + const wrapper = await render() + + const owned = wrapper.findAll('button').find((candidate) => candidate.text() === 'In your library') + expect(owned?.attributes('disabled')).toBeDefined() + }) + + it('offers a format preference row for each half', async () => { + state.pairMode.value = true + const wrapper = await render() + + await button(wrapper, 'More options')!.trigger('click') + + expect(wrapper.text()).toContain('Preferred e-book formats') + expect(wrapper.text()).toContain('Preferred audiobook formats') + expect(wrapper.find('button[aria-label="Prefer M4B"]').exists()).toBe(true) + expect(wrapper.find('button[aria-label="Prefer EPUB"]').exists()).toBe(true) + }) +}) diff --git a/client/src/features/book-requests/__tests__/requestPair.spec.ts b/client/src/features/book-requests/__tests__/requestPair.spec.ts new file mode 100644 index 000000000..aa795bd81 --- /dev/null +++ b/client/src/features/book-requests/__tests__/requestPair.spec.ts @@ -0,0 +1,149 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import type { BookRequestAvailability, BookRequestItem } from '@bookorbit/types' +import { + flattenRequestRows, + orderedHalves, + pairActionKey, + pairHalfState, + pairRowActions, + pairSubmission, + pairSubmissionLabelKey, + type PairAvailability, +} from '../requestPair' + +const OWNED: BookRequestAvailability = { ownedBookId: 42, existingRequestId: null, existingRequestStatus: null, alreadySubscribed: false } +const FOLLOWING: BookRequestAvailability = { ownedBookId: null, existingRequestId: 7, existingRequestStatus: 'pending', alreadySubscribed: true } +const JOINABLE: BookRequestAvailability = { ownedBookId: null, existingRequestId: 8, existingRequestStatus: 'pending', alreadySubscribed: false } + +function pair(ebook: BookRequestAvailability | null, audiobook: BookRequestAvailability | null): PairAvailability { + return { ebook, audiobook } +} + +function request(overrides: Partial = {}): BookRequestItem { + return { + id: 1, + userId: 4, + mediaKind: 'ebook', + status: 'pending', + dismissed: false, + download: null, + fulfillerUserId: null, + ...overrides, + } as BookRequestItem +} + +describe('pairHalfState', () => { + it('reads ownership ahead of any request, and tells a request you follow from one you could join', () => { + expect(pairHalfState(null)).toBe('new') + expect(pairHalfState(OWNED)).toBe('owned') + expect(pairHalfState({ ...OWNED, existingRequestId: 3, alreadySubscribed: true })).toBe('owned') + expect(pairHalfState(FOLLOWING)).toBe('following') + expect(pairHalfState(JOINABLE)).toBe('joinable') + }) +}) + +describe('pairSubmission', () => { + it('sends both halves as a pair when neither has been asked for', () => { + expect(pairSubmission(pair(null, null))).toEqual({ kind: 'pair', actionable: ['ebook', 'audiobook'] }) + }) + + /** The new format should become another format of the book already on the shelf, not a second book. */ + it('sends only the missing half, aimed at the copy on the shelf, when the other is owned', () => { + expect(pairSubmission(pair(OWNED, null))).toEqual({ kind: 'single', mediaKind: 'audiobook', joinBookId: 42, state: 'new' }) + expect(pairSubmission(pair(JOINABLE, OWNED))).toEqual({ kind: 'single', mediaKind: 'ebook', joinBookId: 42, state: 'joinable' }) + }) + + /** The pair route joins the request already followed and links it to the new half. */ + it('still sends a pair when the other half is a request this person already follows', () => { + expect(pairSubmission(pair(FOLLOWING, null))).toEqual({ kind: 'pair', actionable: ['audiobook'] }) + }) + + it('sends nothing once there is nothing left to ask for, and says why', () => { + expect(pairSubmission(pair(OWNED, OWNED))).toEqual({ kind: 'none', reason: 'owned' }) + expect(pairSubmission(pair(OWNED, FOLLOWING))).toEqual({ kind: 'none', reason: 'requested' }) + }) +}) + +describe('pairSubmissionLabelKey', () => { + it('names both, or the one medium a press will actually touch', () => { + const both = pair(null, null) + expect(pairSubmissionLabelKey(pairSubmission(both), both, 'request')).toBe('bookRequests.search.pair.requestBoth') + expect(pairSubmissionLabelKey(pairSubmission(both), both, 'get')).toBe('bookRequests.search.pair.getBoth') + expect(pairSubmissionLabelKey(pairSubmission(both), both, 'choose')).toBe('bookRequests.search.pair.chooseReleases') + + const audioOnly = pair(OWNED, null) + expect(pairSubmissionLabelKey(pairSubmission(audioOnly), audioOnly, 'request')).toBe('bookRequests.search.pair.requestAudiobook') + expect(pairSubmissionLabelKey(pairSubmission(audioOnly), audioOnly, 'get')).toBe('bookRequests.search.pair.getAudiobook') + }) + + it('says "add me" when every half it touches is somebody else\'s request', () => { + const joinBoth = pair(JOINABLE, JOINABLE) + expect(pairSubmissionLabelKey(pairSubmission(joinBoth), joinBoth, 'request')).toBe('bookRequests.search.pair.joinBoth') + + const joinEbook = pair(JOINABLE, OWNED) + expect(pairSubmissionLabelKey(pairSubmission(joinEbook), joinEbook, 'get')).toBe('bookRequests.search.pair.joinEbook') + }) + + it('labels a pair with nothing left to ask for by what is already true', () => { + const owned = pair(OWNED, OWNED) + expect(pairSubmissionLabelKey(pairSubmission(owned), owned, 'request')).toBe('bookRequests.search.pair.haveBoth') + const requested = pair(FOLLOWING, FOLLOWING) + expect(pairSubmissionLabelKey(pairSubmission(requested), requested, 'request')).toBe('bookRequests.search.pair.requestedBoth') + }) +}) + +describe('pair rows', () => { + const ebook = request({ id: 10, mediaKind: 'ebook' }) + const audiobook = request({ id: 11, mediaKind: 'audiobook' }) + + it('unfolds pairs into their requests for selection and bulk actions', () => { + const rows = [{ ...ebook, companion: audiobook }, request({ id: 12 })] + expect(flattenRequestRows(rows).map((item) => item.id)).toEqual([10, 11, 12]) + }) + + it('reads the ebook first whichever half leads the row', () => { + expect(orderedHalves({ ...audiobook, companion: ebook }).map((item) => item.mediaKind)).toEqual(['ebook', 'audiobook']) + }) + + it('offers each action only for the halves it applies to', () => { + const row = { ...ebook, companion: { ...audiobook, status: 'available' as const } } + const actions = pairRowActions(row, { userId: 4, canManage: true, canSelfFulfil: false }) + + expect(actions.approve.map((item) => item.id)).toEqual([10]) + expect(actions.dismiss.map((item) => item.id)).toEqual([11]) + expect(actions.cancel.map((item) => item.id)).toEqual([10]) + expect(actions.grab).toBeNull() + }) + + it('lists every half waiting on a release the viewer can pick, so the row can say "releases"', () => { + const row = { ...ebook, status: 'approved' as const, companion: { ...audiobook, status: 'approved' as const } } + const both = pairRowActions(row, { userId: 4, canManage: true, canSelfFulfil: false }) + expect(both.grabHalves.map((item) => item.id)).toEqual([10, 11]) + expect(both.grab?.id).toBe(10) + + const oneLeft = pairRowActions( + { ...row, companion: { ...audiobook, status: 'downloading' as const } }, + { userId: 4, canManage: true, canSelfFulfil: false }, + ) + expect(oneLeft.grabHalves.map((item) => item.id)).toEqual([10]) + }) + + it('only counts the halves a self-fulfiller drives', () => { + const row = { ...ebook, status: 'approved' as const, companion: { ...audiobook, status: 'approved' as const, userId: 9 } } + const actions = pairRowActions(row, { userId: 4, canManage: false, canSelfFulfil: true }) + expect(actions.grabHalves.map((item) => item.id)).toEqual([10]) + }) + + it('offers no approval to somebody who does not moderate the queue', () => { + const actions = pairRowActions({ ...ebook, companion: audiobook }, { userId: 4, canManage: false, canSelfFulfil: false }) + expect(actions.approve).toEqual([]) + expect(actions.cancel.map((item) => item.id)).toEqual([10, 11]) + }) + + it('names both halves, or the one, in every action label', () => { + expect(pairActionKey('approve', [ebook, audiobook])).toBe('bookRequests.actions.pair.approveBoth') + expect(pairActionKey('cancel', [audiobook])).toBe('bookRequests.actions.pair.cancelAudiobook') + expect(pairActionKey('dismiss', [ebook])).toBe('bookRequests.actions.pair.dismissEbook') + }) +}) diff --git a/client/src/features/book-requests/__tests__/useBookRequests.spec.ts b/client/src/features/book-requests/__tests__/useBookRequests.spec.ts index 3272298bf..8a720cfb5 100644 --- a/client/src/features/book-requests/__tests__/useBookRequests.spec.ts +++ b/client/src/features/book-requests/__tests__/useBookRequests.spec.ts @@ -230,3 +230,53 @@ describe('useBookRequestActions with more than one row in flight', () => { await expect(actions.remove(1)).resolves.toEqual({ ok: false, reason: 'remove the download from its client first' }) }) }) + +describe('useBookRequests pairs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const ebook = { id: 10, mediaKind: 'ebook', status: 'pending', companionRequestId: 11 } + const audiobook = { id: 11, mediaKind: 'audiobook', status: 'pending', companionRequestId: 10 } + + it('asks for a pair as one row', async () => { + apiMock.mockResolvedValue(response({ items: [], total: 0 })) + const requests = await freshRequests('mine') + + await requests.fetchRequests() + + const params = new URLSearchParams(String(apiMock.mock.calls[0]?.[0]).split('?')[1]) + expect(params.get('groupPairs')).toBe('true') + }) + + it('updates the half a decision was about, whichever half leads the row', async () => { + apiMock.mockResolvedValue(response({ items: [{ ...ebook, companion: audiobook }], total: 1 })) + const requests = await freshRequests('mine') + await requests.fetchRequests() + + requests.replaceItem({ ...audiobook, status: 'approved' } as never) + expect(requests.items.value[0]!.companion?.status).toBe('approved') + + // A single request comes back flat, so the lead keeps the companion it already had. + requests.replaceItem({ ...ebook, status: 'approved' } as never) + expect(requests.items.value[0]!.status).toBe('approved') + expect(requests.items.value[0]!.companion?.id).toBe(11) + }) + + it('keeps the other half of a pair on the list when one half leaves it', async () => { + apiMock.mockResolvedValue(response({ items: [{ ...ebook, companion: audiobook }], total: 1 })) + const requests = await freshRequests('mine') + await requests.fetchRequests() + + requests.dropItem(10) + + expect(requests.items.value).toHaveLength(1) + expect(requests.items.value[0]!.id).toBe(11) + expect(requests.items.value[0]!.companion).toBeNull() + expect(requests.total.value).toBe(1) + + requests.dropItem(11) + expect(requests.items.value).toHaveLength(0) + expect(requests.total.value).toBe(0) + }) +}) diff --git a/client/src/features/book-requests/__tests__/useCandidateGroups.spec.ts b/client/src/features/book-requests/__tests__/useCandidateGroups.spec.ts index 132b9ae1f..55fbe3a52 100644 --- a/client/src/features/book-requests/__tests__/useCandidateGroups.spec.ts +++ b/client/src/features/book-requests/__tests__/useCandidateGroups.spec.ts @@ -257,3 +257,56 @@ describe('useCandidateGroups', () => { expect(groupsFor(results, {}, 'ebook')[0]!.key).not.toBe(groupsFor(results, {}, 'audiobook')[0]!.key) }) }) + +describe('useCandidateGroups for a pair search', () => { + function pairGroups(results: MetadataCandidate[], availability: Record) { + const { groups } = useCandidateGroups( + ref(results), + ref('ebook'), + (item, kind = 'ebook') => availability[`${kind}|${item.provider}:${item.providerId}`] ?? null, + ref([MetadataProviderKey.AMAZON]), + ref(null), + ref([]), + { enabled: ref(true), audioCoverProviderOrder: ref([MetadataProviderKey.AUDIBLE]) }, + ) + return groups.value + } + + /** Owning the ebook says nothing about the audiobook, so each half is answered on its own. */ + it('answers each medium separately, merged across the records behind the work', () => { + const groups = pairGroups( + [ + candidate({ provider: MetadataProviderKey.AMAZON, providerId: 'az1' }), + candidate({ provider: MetadataProviderKey.AUDIBLE, providerId: 'au1' }), + ], + { + 'ebook|amazon:az1': { ownedBookId: 42, existingRequestId: null, existingRequestStatus: null, alreadySubscribed: false }, + 'audiobook|audible:au1': { ownedBookId: null, existingRequestId: 9, existingRequestStatus: 'pending', alreadySubscribed: false }, + }, + ) + + expect(groups).toHaveLength(1) + expect(groups[0]!.pairAvailability?.ebook?.ownedBookId).toBe(42) + expect(groups[0]!.pairAvailability?.audiobook?.existingRequestId).toBe(9) + }) + + it("orders the audiobook half's covers by the Audiobook cover rule, apart from the ebook's", () => { + const groups = pairGroups( + [ + candidate({ provider: MetadataProviderKey.AMAZON, providerId: 'az1', coverUrl: 'https://covers.test/portrait.jpg' }), + candidate({ provider: MetadataProviderKey.AUDIBLE, providerId: 'au1', coverUrl: 'https://covers.test/square.jpg' }), + ], + {}, + ) + + expect(groups[0]!.coverUrl).toBe('https://covers.test/portrait.jpg') + expect(groups[0]!.audioCoverUrls[0]).toBe('https://covers.test/square.jpg') + }) + + it('carries no pair answer at all outside a pair search', () => { + const groups = groupsFor([candidate({ provider: MetadataProviderKey.AMAZON, providerId: 'az1' })]) + + expect(groups[0]!.pairAvailability).toBeNull() + expect(groups[0]!.audioCoverUrls).toEqual([]) + }) +}) diff --git a/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts b/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts index d5857e9b6..aaa08153c 100644 --- a/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts +++ b/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts @@ -3,12 +3,13 @@ import { ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BookRequestItem, DownloadDelivery, ReleaseCandidateItem, ReleaseFileInspection } from '@bookorbit/types' -const { toastMock } = vi.hoisted(() => ({ +const { toastMock, pushMock } = vi.hoisted(() => ({ toastMock: { error: vi.fn<(title: string, options?: unknown) => void>(), success: vi.fn<(title: string) => void>() }, + pushMock: vi.fn<(to: unknown) => void>(), })) vi.mock('vue-sonner', () => ({ toast: toastMock })) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) -vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: vi.fn<() => void>() }) })) +vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push: pushMock }) })) import { useReleaseGrab, type ReleaseGrabOutcome } from '../composables/useReleaseGrab' @@ -31,6 +32,7 @@ function makeGrab( grab?: ReturnType inspectRelease?: ReturnType deliveryFor?: (release: ReleaseCandidateItem) => DownloadDelivery + afterGrab?: (item: BookRequestItem) => void } = {}, ) { const grab = overrides.grab ?? vi.fn<() => Promise>().mockResolvedValue({ item: request, reason: null, errorCode: null }) @@ -47,6 +49,7 @@ function makeGrab( inspectRelease: inspectRelease as never, setFilesExpanded, deliveryFor: overrides.deliveryFor ?? (() => 'torrent'), + afterGrab: overrides.afterGrab, }), grab, inspectRelease, @@ -60,6 +63,25 @@ describe('useReleaseGrab', () => { vi.clearAllMocks() }) + it('returns to the request a release was sent for', async () => { + const picker = makeGrab() + + await picker.handleGrab(release()) + + expect(pushMock).toHaveBeenCalledWith({ name: 'book-request-detail', params: { id: 7 }, query: {} }) + }) + + it('leaves where to go next to the picker when it asks to decide', async () => { + const afterGrab = vi.fn<(item: BookRequestItem) => void>() + const picker = makeGrab({ afterGrab }) + + await picker.handleGrab(release()) + + expect(afterGrab).toHaveBeenCalledWith(request) + expect(pushMock).not.toHaveBeenCalled() + expect(toastMock.success).toHaveBeenCalledWith('bookRequests.toasts.grabbed') + }) + it('inspects a release and sends it', async () => { const picker = makeGrab() diff --git a/client/src/features/book-requests/__tests__/useReleasePicker.spec.ts b/client/src/features/book-requests/__tests__/useReleasePicker.spec.ts index 406653f3c..d43ff0406 100644 --- a/client/src/features/book-requests/__tests__/useReleasePicker.spec.ts +++ b/client/src/features/book-requests/__tests__/useReleasePicker.spec.ts @@ -5,7 +5,13 @@ import type { ReleaseCandidateItem, ReleaseFileInspection } from '@bookorbit/typ const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn<(url: string, init?: RequestInit) => Promise>() })) vi.mock('@/lib/api', () => ({ api: apiMock })) -import { TITLE_AUTHOR_SEARCH_KEY, useReleasePicker } from '../composables/useReleasePicker' +import { + TITLE_AUTHOR_SEARCH_KEY, + clearReleaseSearchCache, + peekReleaseSearch, + prefetchReleaseSearch, + useReleasePicker, +} from '../composables/useReleasePicker' function ok(body: unknown): Response { return { ok: true, status: 200, json: async () => body } as Response @@ -59,6 +65,7 @@ const inspection: ReleaseFileInspection = { describe('useReleasePicker', () => { beforeEach(() => { vi.clearAllMocks() + clearReleaseSearchCache() apiMock.mockResolvedValue(ok(inspection)) }) @@ -316,6 +323,8 @@ describe('useReleasePicker', () => { describe('useReleasePicker out-of-order searches', () => { beforeEach(() => { vi.clearAllMocks() + apiMock.mockReset() + clearReleaseSearchCache() }) function deferred() { @@ -356,4 +365,76 @@ describe('useReleasePicker out-of-order searches', () => { expect(picker.releases.value).toEqual([]) expect(picker.searched.value).toBe(false) }) + + /** The picker for a pair searches both halves on open, and the trackers must only be asked once. */ + describe('a default search shared across pickers', () => { + it('answers from a search already started ahead of time instead of asking again', async () => { + const found = deferred() + apiMock.mockImplementationOnce(() => found.pending) + prefetchReleaseSearch(true, 11) + const picker = useReleasePicker(() => true) + + const loaded = picker.fetchReleases(11) + found.release(searchResult({ releases: [release] })) + + await expect(loaded).resolves.toBe(true) + expect(apiMock).toHaveBeenCalledTimes(1) + expect(apiMock).toHaveBeenCalledWith('/api/v1/admin/book-requests/11/releases') + expect(picker.releases.value).toEqual([release]) + }) + + it('shows a search it already has in the same tick, without a loading state', async () => { + apiMock.mockResolvedValue(ok(searchResult({ releases: [release, release] }))) + await useReleasePicker(() => true).fetchReleases(11) + const picker = useReleasePicker(() => true) + + void picker.fetchReleases(11) + + expect(picker.releases.value).toHaveLength(2) + expect(picker.loading.value).toBe(false) + expect(apiMock).toHaveBeenCalledTimes(1) + }) + + it('asks the trackers again when the search is refreshed', async () => { + apiMock.mockResolvedValue(ok(searchResult({ releases: [release] }))) + const picker = useReleasePicker(() => true) + await picker.fetchReleases(11) + + await picker.fetchReleases(11, { refresh: true }) + + expect(apiMock).toHaveBeenLastCalledWith('/api/v1/admin/book-requests/11/releases?refresh=true') + expect(apiMock).toHaveBeenCalledTimes(2) + }) + + it('says a search is running, then how many releases it found', async () => { + const found = deferred() + apiMock.mockImplementationOnce(() => found.pending) + + prefetchReleaseSearch(true, 11) + expect(peekReleaseSearch(true, 11)).toEqual({ searching: true, count: null }) + + found.release(searchResult({ releases: [release, release, release] })) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(peekReleaseSearch(true, 11)).toEqual({ searching: false, count: 3 }) + }) + + it('forgets a failed search, so the next look asks again', async () => { + apiMock.mockResolvedValueOnce({ ok: false, status: 502, json: async () => ({}) } as Response) + prefetchReleaseSearch(true, 11) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(peekReleaseSearch(true, 11)).toEqual({ searching: false, count: null }) + + apiMock.mockResolvedValueOnce(ok(searchResult({ releases: [release] }))) + await useReleasePicker(() => true).fetchReleases(11) + expect(apiMock).toHaveBeenCalledTimes(2) + }) + + it('keeps a self-fulfiller and a moderator apart, since they search different endpoints', async () => { + apiMock.mockResolvedValue(ok(searchResult({ releases: [release] }))) + prefetchReleaseSearch(true, 11) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(peekReleaseSearch(false, 11)).toEqual({ searching: false, count: null }) + }) + }) }) diff --git a/client/src/features/book-requests/__tests__/useRequestDestinationDefault.spec.ts b/client/src/features/book-requests/__tests__/useRequestDestinationDefault.spec.ts index ef7881e01..ecb4941b0 100644 --- a/client/src/features/book-requests/__tests__/useRequestDestinationDefault.spec.ts +++ b/client/src/features/book-requests/__tests__/useRequestDestinationDefault.spec.ts @@ -60,7 +60,6 @@ describe('useRequestDestinationDefault', () => { expect(apiMock).toHaveBeenCalledWith('/api/v1/book-requests/default-destinations') expect(preferences.defaultFor('ebook')).toEqual({ libraryId: 4, libraryName: 'Novels', folderId: 9 }) expect(preferences.defaultFor('audiobook')).toEqual({ libraryId: 5, libraryName: 'Audiobooks', folderId: 10 }) - expect(preferences.defaultFor('comic')).toEqual({ libraryId: null, libraryName: null, folderId: null }) }) it('reads a malformed body as no default rather than a half-made destination', async () => { diff --git a/client/src/features/book-requests/__tests__/useRequestQueue.spec.ts b/client/src/features/book-requests/__tests__/useRequestQueue.spec.ts new file mode 100644 index 000000000..b70d85329 --- /dev/null +++ b/client/src/features/book-requests/__tests__/useRequestQueue.spec.ts @@ -0,0 +1,27 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { ref } from 'vue' +import type { BookRequestItem } from '@bookorbit/types' +import { useRequestQueue } from '../composables/useRequestQueue' + +function row(id: number, companionId?: number): BookRequestItem { + return { id, companion: companionId === undefined ? null : ({ id: companionId } as BookRequestItem) } as BookRequestItem +} + +describe('useRequestQueue', () => { + /** The drawer can be open on the audiobook while the row it belongs to is led by the ebook. */ + it("places either half of a pair at the pair's row, and steps to the rows around it", () => { + const queue = useRequestQueue(ref([row(1), row(10, 11), row(2)]), ref(11)) + + expect(queue.position.value).toBe(2) + expect(queue.previousId.value).toBe(1) + expect(queue.nextId.value).toBe(2) + }) + + it('hides the position of a request the list is not showing', () => { + const queue = useRequestQueue(ref([row(1)]), ref(99)) + + expect(queue.position.value).toBe(0) + expect(queue.hasNext.value).toBe(false) + }) +}) diff --git a/client/src/features/book-requests/__tests__/useRequestSubmission.spec.ts b/client/src/features/book-requests/__tests__/useRequestSubmission.spec.ts index 17d74261e..4ad1c95d5 100644 --- a/client/src/features/book-requests/__tests__/useRequestSubmission.spec.ts +++ b/client/src/features/book-requests/__tests__/useRequestSubmission.spec.ts @@ -189,3 +189,97 @@ describe('submit refusals', () => { scope.stop() }) }) + +describe('useRequestSubmission for a pair', () => { + const dune = { provider: MetadataProviderKey.GOOGLE, providerId: 'g1', title: 'Dune', authors: ['Frank Herbert'] } as MetadataCandidate + + beforeEach(() => { + api.mockReset() + }) + + it('asks about both media for every candidate, and files each answer under its own medium', async () => { + vi.useFakeTimers() + try { + api.mockResolvedValueOnce({ + ok: true, + json: async () => [ + { ownedBookId: 42, existingRequestId: null, existingRequestStatus: null, alreadySubscribed: false }, + { ownedBookId: null, existingRequestId: 9, existingRequestStatus: 'pending', alreadySubscribed: false }, + ], + } as Response) + const scope = effectScope() + const state = scope.run(() => useRequestSubmission())! + state.pairMode.value = true + await Promise.resolve() + + state.annotate([dune]) + await vi.runAllTimersAsync() + + const body = JSON.parse(api.mock.calls[0]![1]!.body as string) as { items: Array<{ mediaKind: string }> } + expect(body.items.map((item) => item.mediaKind)).toEqual(['ebook', 'audiobook']) + expect(state.getAvailability(dune, 'ebook')?.ownedBookId).toBe(42) + expect(state.getAvailability(dune, 'audiobook')?.existingRequestId).toBe(9) + scope.stop() + } finally { + vi.useRealTimers() + } + }) + + it('submits both halves in one call, each with its own destination, formats and cover', async () => { + api.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ebook: { request: { id: 10, status: 'pending' }, subscribed: false }, + audiobook: { request: { id: 11, status: 'pending' }, subscribed: true }, + }), + } as Response) + const scope = effectScope() + const state = scope.run(() => useRequestSubmission())! + + const result = await state.submitPair(dune, { + language: 'en', + ebook: { targetLibraryId: 1, preferredFormats: ['epub'] }, + audiobook: { targetLibraryId: 2, preferredFormats: ['m4b'], coverUrl: 'https://covers.test/square.jpg' }, + }) + + const [path, init] = api.mock.calls[0]! + expect(path).toBe('/api/v1/book-requests/pairs') + const body = JSON.parse(init!.body as string) + expect(body).not.toHaveProperty('mediaKind') + expect(body.ebook).toEqual({ targetLibraryId: 1, targetFolderId: null, preferredFormats: ['epub'], coverUrl: null }) + expect(body.audiobook).toEqual({ + targetLibraryId: 2, + targetFolderId: null, + preferredFormats: ['m4b'], + coverUrl: 'https://covers.test/square.jpg', + }) + expect(result?.audiobook.subscribed).toBe(true) + // What the server just said stands in for both halves until the next annotation. + expect(state.getAvailability(dune, 'ebook')?.existingRequestId).toBe(10) + expect(state.getAvailability(dune, 'audiobook')?.existingRequestId).toBe(11) + scope.stop() + }) + + it('asks for one medium with the book it should join when only that one is missing', async () => { + api.mockResolvedValueOnce({ ok: true, json: async () => ({ request: { id: 12, status: 'pending' }, subscribed: false }) } as Response) + const scope = effectScope() + const state = scope.run(() => useRequestSubmission())! + + await state.submit(dune, { mediaKind: 'audiobook', targetBookId: 42 }) + + const body = JSON.parse(api.mock.calls[0]![1]!.body as string) + expect(body).toMatchObject({ mediaKind: 'audiobook', targetBookId: 42 }) + scope.stop() + }) + + it('leaves the book field off entirely for an ordinary request, so an older server never sees it', async () => { + api.mockResolvedValueOnce({ ok: true, json: async () => ({ request: { id: 12, status: 'pending' }, subscribed: false }) } as Response) + const scope = effectScope() + const state = scope.run(() => useRequestSubmission())! + + await state.submit(dune, {}) + + expect(JSON.parse(api.mock.calls[0]![1]!.body as string)).not.toHaveProperty('targetBookId') + scope.stop() + }) +}) diff --git a/client/src/features/book-requests/components/BookMissingFormatRequest.vue b/client/src/features/book-requests/components/BookMissingFormatRequest.vue new file mode 100644 index 000000000..6b2bc1cb1 --- /dev/null +++ b/client/src/features/book-requests/components/BookMissingFormatRequest.vue @@ -0,0 +1,110 @@ + + + diff --git a/client/src/features/book-requests/components/ReleasePairTabs.vue b/client/src/features/book-requests/components/ReleasePairTabs.vue new file mode 100644 index 000000000..2fe7ef784 --- /dev/null +++ b/client/src/features/book-requests/components/ReleasePairTabs.vue @@ -0,0 +1,86 @@ + + + diff --git a/client/src/features/book-requests/components/ReleasePickerPanel.vue b/client/src/features/book-requests/components/ReleasePickerPanel.vue index a829ebe3f..60ec13905 100644 --- a/client/src/features/book-requests/components/ReleasePickerPanel.vue +++ b/client/src/features/book-requests/components/ReleasePickerPanel.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { ArrowUpRight, + Check, ChevronDown, ChevronUp, CircleCheck, @@ -23,8 +24,10 @@ import { TriangleAlert, Zap, } from '@lucide/vue' -import { Permission, releaseInspectionBlocksGrab } from '@bookorbit/types' +import { isBookRequestFulfiller, isGrabbableBookRequestStatus, Permission, releaseInspectionBlocksGrab } from '@bookorbit/types' import type { + BookRequestItem, + BookRequestStatus, DownloadDelivery, IndexerSearchFailure, ReleaseCandidateItem, @@ -38,6 +41,8 @@ import { formatDate, formatLanguageName, formatList } from '@/i18n/formatters' import { formatBytes } from '@/lib/formatting' import GrabReleaseDialog from './GrabReleaseDialog.vue' import ReleaseFileInspectionPanel from './ReleaseFileInspectionPanel.vue' +import ReleasePairTabs from './ReleasePairTabs.vue' +import type { ReleasePairTab } from './ReleasePairTabs.vue' import RequestCover from './RequestCover.vue' import RequestDrawerToolbar from './RequestDrawerToolbar.vue' import RequestEmptyState from './RequestEmptyState.vue' @@ -51,8 +56,9 @@ import { useDownloadClientSummaries } from '../composables/useDownloadClients' import { useReleaseFacetGroups } from '../composables/useReleaseFacetGroups' import { useReleaseFilters } from '../composables/useReleaseFilters' import { useReleaseGrab } from '../composables/useReleaseGrab' -import { useReleasePicker } from '../composables/useReleasePicker' +import { peekReleaseSearch, prefetchReleaseSearch, useReleasePicker } from '../composables/useReleasePicker' import { useReleaseSearchEditor } from '../composables/useReleaseSearchEditor' +import { useAuth } from '@/features/auth/composables/useAuth' import { usePermissions } from '@/features/auth/composables/usePermissions' const { t } = useI18n() @@ -84,6 +90,13 @@ const { reset, } = useReleasePicker(canManage) const { request, loading: requestLoading, error: requestError, fetchRequest, setRequest } = useBookRequestDetail() +const { user } = useAuth() +const companionDetail = useBookRequestDetail() +/** The other half of a pair, once both ends of the link agree. */ +const companion = computed(() => { + const other = companionDetail.request.value + return other && request.value && other.id === request.value.companionRequestId && other.companionRequestId === request.value.id ? other : null +}) const { clients, fetchClients } = useDownloadClientSummaries(canManage) const torrentClients = computed(() => clients.value.filter((client) => client.delivery === 'torrent')) const actions = useBookRequestActions(canManage) @@ -95,6 +108,37 @@ const MAX_LISTED_SOURCES = 4 const requestId = computed(() => Number(route.params.id)) +/** Mirrors `RequestDetailPanel.canFulfilThis`, for whichever half it is asked about. */ +function canDrive(half: BookRequestItem): boolean { + return canManage() || (hasPermission(Permission.BookRequestSelfFulfill) && isBookRequestFulfiller(half, user.value?.id)) +} + +function isChoosable(half: BookRequestItem): boolean { + return canDrive(half) && isGrabbableBookRequestStatus(half.status) +} + +/** A half past choosing: its release is on its way or already in the library. */ +const SETTLED_STATUSES: readonly BookRequestStatus[] = ['grabbed', 'downloading', 'importing', 'needs_review', 'available'] +/** Set by the send that leaves neither half needing a release, and cleared when the picker moves. */ +const finished = ref(false) +/** + * The list the drawer sits on, without what only the first search was opened with. An ISBN picked + * for one half's search says nothing about the other's, so switching halves searches afresh. + */ +const listQuery = computed(() => Object.fromEntries(Object.entries(route.query).filter(([key]) => key !== 'isbn' && key !== 'search'))) +const pairTabs = computed(() => { + const current = request.value + const partner = companion.value + if (!current || !partner) return null + return [current, partner] + .sort((a, b) => (a.mediaKind === b.mediaKind ? 0 : a.mediaKind === 'ebook' ? -1 : 1)) + .map((half) => { + if (half.id !== current.id) return { request: half, current: false, choosable: isChoosable(half), ...peekReleaseSearch(canManage(), half.id) } + const count = searched.value && !loading.value ? releases.value.length : null + return { request: half, current: true, choosable: isChoosable(half), count, searching: loading.value } + }) +}) + const filters = useReleaseFilters(releases) const { visibleReleases, hasFilter, showFacets, marksBest, tiedWithBest, clearFilters, resetFilters } = filters @@ -142,6 +186,7 @@ const { manualOpen, grabbing, openManual, closeManual, forgetRefusals, handleGra inspectRelease, setFilesExpanded, deliveryFor, + afterGrab, }) /** @@ -346,6 +391,25 @@ onMounted(async () => { if (Number.isInteger(requestId.value)) void fetchInitialReleases(requestId.value) }) +watch( + () => request.value?.companionRequestId ?? null, + (companionId) => { + if (companionId === null) { + companionDetail.request.value = null + return + } + void companionDetail.fetchRequest(companionId, { background: companionDetail.request.value?.id === companionId }) + }, +) + +// Both halves are searched once the picker is open on either, so the second never makes anyone wait. +watch( + () => (companion.value && isChoosable(companion.value) ? companion.value.id : null), + (companionId) => { + if (companionId !== null) prefetchReleaseSearch(canManage(), companionId) + }, +) + watch(requestId, (id) => { if (!Number.isInteger(id)) return reset() @@ -354,10 +418,41 @@ watch(requestId, (id) => { showEachSource.value = false expandedFiles.value = new Set() resetFilters() - void fetchRequest(id) + finished.value = false + // Switching between the halves of a pair: both are already loaded, so they trade places at once. + const partner = companion.value + const previous = request.value + if (partner && previous && partner.id === id) { + setRequest(partner) + companionDetail.setRequest(previous) + void fetchRequest(id, { background: true }) + } else { + void fetchRequest(id) + } void fetchInitialReleases(id) }) +/** + * Where a sent release leaves a pair: on the other half while it still needs one, on a closing + * state once neither does, and otherwise back on the request as for any single release. + */ +function afterGrab(item: BookRequestItem): void { + const partner = companion.value + if (partner && isChoosable(partner)) { + void router.push({ name: 'book-request-releases', params: { id: partner.id }, query: listQuery.value }) + return + } + if (partner && SETTLED_STATUSES.includes(partner.status)) { + finished.value = true + return + } + void router.push({ name: 'book-request-detail', params: { id: item.id }, query: route.query }) +} + +function closeDrawer(): void { + void router.push({ name: 'book-requests', query: listQuery.value }) +} + function releaseKey(release: ReleaseCandidateItem): string { return `${release.indexerId}:${release.guid}` } @@ -669,10 +764,10 @@ function showEverySource(): void { -