mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add empty entity cleanup controls
Expose zero-book first-class metadata entities in browse so admins can review orphaned rows before deleting them. Keep inline metadata values out of empty filtering because they only exist through book metadata rows. Closes #370
This commit is contained in:
@@ -39,13 +39,14 @@ describe('browseEntities', () => {
|
||||
const data = { items: [], total: 0 }
|
||||
mockedApi.mockResolvedValue(mockOkResponse(data))
|
||||
|
||||
const result = await browseEntities('genre', { page: 1, pageSize: 20, sortBy: 'name' })
|
||||
const result = await browseEntities('genre', { page: 1, pageSize: 20, sortBy: 'name', bookCount: 'empty' })
|
||||
|
||||
const url = mockedApi.mock.calls[0][0] as string
|
||||
expect(url).toContain(`${BASE}/genre/browse`)
|
||||
expect(url).toContain('page=1')
|
||||
expect(url).toContain('pageSize=20')
|
||||
expect(url).toContain('sortBy=name')
|
||||
expect(url).toContain('bookCount=empty')
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
BrowseEntitiesParams,
|
||||
BrowseEntitiesResponse,
|
||||
BulkDeleteResult,
|
||||
DeleteResult,
|
||||
@@ -21,11 +22,15 @@ function toQuery(params: Record<string, string | number | undefined>): string {
|
||||
|
||||
const BASE = '/api/v1/entity-manager'
|
||||
|
||||
export async function browseEntities(
|
||||
entityType: EntityType,
|
||||
params: { search?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: string },
|
||||
): Promise<BrowseEntitiesResponse> {
|
||||
const query = toQuery({ search: params.search, page: params.page, pageSize: params.pageSize, sortBy: params.sortBy, sortOrder: params.sortOrder })
|
||||
export async function browseEntities(entityType: EntityType, params: BrowseEntitiesParams): Promise<BrowseEntitiesResponse> {
|
||||
const query = toQuery({
|
||||
search: params.search,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
sortBy: params.sortBy,
|
||||
sortOrder: params.sortOrder,
|
||||
bookCount: params.bookCount,
|
||||
})
|
||||
const res = await api(`${BASE}/${entityType}/browse${query}`)
|
||||
if (!res.ok) throw new Error(`Failed to browse ${entityType}`)
|
||||
return res.json()
|
||||
|
||||
@@ -238,7 +238,10 @@ describe('useEntityManager', () => {
|
||||
|
||||
await fetchBrowse()
|
||||
|
||||
expect(mockBrowseEntities).toHaveBeenCalledWith('author', expect.objectContaining({ page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc' }))
|
||||
expect(mockBrowseEntities).toHaveBeenCalledWith(
|
||||
'author',
|
||||
expect.objectContaining({ page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' }),
|
||||
)
|
||||
expect(browseItems.value).toEqual(items)
|
||||
expect(browseTotal.value).toBe(10)
|
||||
})
|
||||
@@ -285,18 +288,30 @@ describe('useEntityManager', () => {
|
||||
|
||||
it('passes search, sortBy, sortOrder parameters', async () => {
|
||||
mockBrowseEntities.mockResolvedValue(makeBrowseResponse([]))
|
||||
const { fetchBrowse, browseSearch, browseSortBy, browseSortOrder } = useEntityManager()
|
||||
const { fetchBrowse, browseSearch, browseSortBy, browseSortOrder, browseBookCount } = useEntityManager()
|
||||
browseSearch.value = 'tolkien'
|
||||
browseSortBy.value = 'bookCount'
|
||||
browseSortOrder.value = 'desc'
|
||||
browseBookCount.value = 'empty'
|
||||
|
||||
await fetchBrowse()
|
||||
|
||||
expect(mockBrowseEntities).toHaveBeenCalledWith(
|
||||
'author',
|
||||
expect.objectContaining({ search: 'tolkien', sortBy: 'bookCount', sortOrder: 'desc' }),
|
||||
expect.objectContaining({ search: 'tolkien', sortBy: 'bookCount', sortOrder: 'desc', bookCount: 'empty' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('sends any book count filtering for inline entity types', async () => {
|
||||
mockBrowseEntities.mockResolvedValue(makeBrowseResponse([]))
|
||||
const { fetchBrowse, entityType, browseBookCount } = useEntityManager()
|
||||
entityType.value = 'publisher'
|
||||
browseBookCount.value = 'empty'
|
||||
|
||||
await fetchBrowse()
|
||||
|
||||
expect(mockBrowseEntities).toHaveBeenCalledWith('publisher', expect.objectContaining({ bookCount: 'any' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeEntities()', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ENTITY_CAPABILITIES,
|
||||
INLINE_ENTITY_TYPES,
|
||||
type BrowseEntityItem,
|
||||
type BrowseEntityBookCountFilter,
|
||||
type DuplicateCluster,
|
||||
type DuplicateScanStatus,
|
||||
type DismissedPairInfo,
|
||||
@@ -51,6 +52,7 @@ export function useEntityManager() {
|
||||
const browseSearch = ref('')
|
||||
const browseSortBy = ref<'name' | 'bookCount'>('name')
|
||||
const browseSortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const browseBookCount = ref<BrowseEntityBookCountFilter>('any')
|
||||
const browseLoading = ref(false)
|
||||
|
||||
// Selection state
|
||||
@@ -154,6 +156,9 @@ export function useEntityManager() {
|
||||
watch(entityType, () => {
|
||||
clearScan()
|
||||
clearBrowse()
|
||||
if (isInline.value) {
|
||||
browseBookCount.value = 'any'
|
||||
}
|
||||
showDismissed.value = false
|
||||
dismissedPairs.value = []
|
||||
stopStatusPoll()
|
||||
@@ -208,6 +213,7 @@ export function useEntityManager() {
|
||||
pageSize: browsePageSize.value,
|
||||
sortBy: browseSortBy.value,
|
||||
sortOrder: browseSortOrder.value,
|
||||
bookCount: isInline.value ? 'any' : browseBookCount.value,
|
||||
})
|
||||
browseItems.value = result.items
|
||||
browseTotal.value = result.total
|
||||
@@ -451,6 +457,7 @@ export function useEntityManager() {
|
||||
browseSearch,
|
||||
browseSortBy,
|
||||
browseSortOrder,
|
||||
browseBookCount,
|
||||
browseLoading,
|
||||
browseTotalPages,
|
||||
fetchBrowse,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, X } from 'lucide-vue-next'
|
||||
const props = defineProps<{
|
||||
count: number
|
||||
isInline: boolean
|
||||
defaultMode?: 'soft' | 'hard'
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
@@ -13,7 +14,7 @@ const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const deleteMode = ref<'soft' | 'hard'>(props.isInline ? 'hard' : 'soft')
|
||||
const deleteMode = ref<'soft' | 'hard'>(props.isInline ? 'hard' : (props.defaultMode ?? 'soft'))
|
||||
const writeFiles = ref(false)
|
||||
|
||||
function handleConfirm(): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, X } from 'lucide-vue-next'
|
||||
const props = defineProps<{
|
||||
entityName: string
|
||||
isInline: boolean
|
||||
defaultMode?: 'soft' | 'hard'
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
@@ -13,7 +14,7 @@ const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const deleteMode = ref<'soft' | 'hard'>(props.isInline ? 'hard' : 'soft')
|
||||
const deleteMode = ref<'soft' | 'hard'>(props.isInline ? 'hard' : (props.defaultMode ?? 'soft'))
|
||||
const writeFiles = ref(false)
|
||||
|
||||
function handleConfirm(): void {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, GitMerge, MoreHorizontal, Pencil, Search, Scissors, Trash2, X } from 'lucide-vue-next'
|
||||
import type { BrowseEntityItem, EntityTypeCapabilities } from '@bookorbit/types'
|
||||
import type {
|
||||
BrowseEntityBookCountFilter,
|
||||
BrowseEntityItem,
|
||||
BrowseEntitySortBy,
|
||||
BrowseEntitySortOrder,
|
||||
EntityTypeCapabilities,
|
||||
} from '@bookorbit/types'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
type BrowseSortOption = 'name-asc' | 'name-desc' | 'bookCount-asc' | 'bookCount-desc'
|
||||
|
||||
const props = defineProps<{
|
||||
items: BrowseEntityItem[]
|
||||
total: number
|
||||
@@ -11,6 +19,9 @@ const props = defineProps<{
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
search: string
|
||||
sortBy: BrowseEntitySortBy
|
||||
sortOrder: BrowseEntitySortOrder
|
||||
bookCount: BrowseEntityBookCountFilter
|
||||
loading: boolean
|
||||
selectedIds: Set<number | string>
|
||||
capabilities: EntityTypeCapabilities
|
||||
@@ -20,6 +31,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:page': [value: number]
|
||||
'update:search': [value: string]
|
||||
'update:bookCount': [value: BrowseEntityBookCountFilter]
|
||||
sortChange: [sortBy: BrowseEntitySortBy, sortOrder: BrowseEntitySortOrder]
|
||||
select: [id: number | string, event: MouseEvent]
|
||||
rename: [item: BrowseEntityItem]
|
||||
delete: [item: BrowseEntityItem]
|
||||
@@ -34,6 +47,28 @@ function handleSearchInput(event: Event): void {
|
||||
emit('update:search', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function handleSortChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value as BrowseSortOption
|
||||
switch (value) {
|
||||
case 'name-desc':
|
||||
emit('sortChange', 'name', 'desc')
|
||||
break
|
||||
case 'bookCount-asc':
|
||||
emit('sortChange', 'bookCount', 'asc')
|
||||
break
|
||||
case 'bookCount-desc':
|
||||
emit('sortChange', 'bookCount', 'desc')
|
||||
break
|
||||
default:
|
||||
emit('sortChange', 'name', 'asc')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmptyOnlyChange(event: Event): void {
|
||||
emit('update:bookCount', (event.target as HTMLInputElement).checked ? 'empty' : 'any')
|
||||
}
|
||||
|
||||
function handlePrevPage(): void {
|
||||
emit('update:page', props.page - 1)
|
||||
}
|
||||
@@ -66,8 +101,14 @@ function handleBulkMerge(): void {
|
||||
emit('bulkMerge')
|
||||
}
|
||||
|
||||
function handleClearSelection(): void {
|
||||
emit('clearSelection')
|
||||
}
|
||||
|
||||
const hasSelection = computed(() => props.selectedIds.size > 0)
|
||||
const canMerge = computed(() => props.selectedIds.size >= 2)
|
||||
const sortValue = computed<BrowseSortOption>(() => `${props.sortBy}-${props.sortOrder}` as BrowseSortOption)
|
||||
const emptyOnly = computed(() => props.bookCount === 'empty')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,10 +127,24 @@ const canMerge = computed(() => props.selectedIds.size >= 2)
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
:value="sortValue"
|
||||
class="h-8 rounded-md border border-border bg-background px-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="bookCount-asc">Fewest books</option>
|
||||
<option value="bookCount-desc">Most books</option>
|
||||
</select>
|
||||
<label v-if="!isInline" class="inline-flex h-8 items-center gap-2 rounded-md border border-border px-2 text-sm text-muted-foreground">
|
||||
<input type="checkbox" class="rounded accent-primary" :checked="emptyOnly" @change="handleEmptyOnlyChange" />
|
||||
<span>Empty only</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="hasSelection"
|
||||
class="inline-flex items-center gap-1 h-8 px-2 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors mr-1"
|
||||
@click="emit('clearSelection')"
|
||||
@click="handleClearSelection"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
<span class="text-sm font-medium">Clear</span>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import BulkDeleteModal from '../BulkDeleteModal.vue'
|
||||
|
||||
function mountModal(overrides: Partial<InstanceType<typeof BulkDeleteModal>['$props']> = {}) {
|
||||
return mount(BulkDeleteModal, {
|
||||
props: {
|
||||
count: 3,
|
||||
isInline: false,
|
||||
loading: false,
|
||||
...overrides,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('BulkDeleteModal', () => {
|
||||
it('uses the default delete mode when confirming', async () => {
|
||||
const wrapper = mountModal({ defaultMode: 'hard' })
|
||||
|
||||
await wrapper.findAll('button').at(-1)!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual(['hard', false])
|
||||
})
|
||||
|
||||
it('emits inline mode for inline entity deletes', async () => {
|
||||
const wrapper = mountModal({ isInline: true, defaultMode: 'soft' })
|
||||
|
||||
await wrapper.findAll('button').at(-1)!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).not.toContain('Delete mode')
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual(['inline', false])
|
||||
})
|
||||
|
||||
it('emits selected mode and write-files preference', async () => {
|
||||
const wrapper = mountModal()
|
||||
const radios = wrapper.findAll('input[type="radio"]')
|
||||
|
||||
await radios.find((radio) => radio.attributes('value') === 'hard')!.setValue()
|
||||
await wrapper.find('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').at(-1)!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual(['hard', true])
|
||||
})
|
||||
|
||||
it('emits cancel from the close button and backdrop', async () => {
|
||||
const wrapper = mountModal()
|
||||
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
await wrapper.find('.fixed').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import DeleteModal from '../DeleteModal.vue'
|
||||
|
||||
function mountModal(overrides: Partial<InstanceType<typeof DeleteModal>['$props']> = {}) {
|
||||
return mount(DeleteModal, {
|
||||
props: {
|
||||
entityName: 'Empty Author',
|
||||
isInline: false,
|
||||
loading: false,
|
||||
...overrides,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('DeleteModal', () => {
|
||||
it('uses the default delete mode when confirming', async () => {
|
||||
const wrapper = mountModal({ defaultMode: 'hard' })
|
||||
|
||||
await wrapper.findAll('button').at(-1)!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual(['hard', false])
|
||||
})
|
||||
|
||||
it('emits inline mode for inline entity deletes', async () => {
|
||||
const wrapper = mountModal({ isInline: true, defaultMode: 'soft' })
|
||||
|
||||
await wrapper.findAll('button').at(-1)!.trigger('click')
|
||||
|
||||
expect(wrapper.text()).not.toContain('Delete mode')
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual(['inline', false])
|
||||
})
|
||||
|
||||
it('emits selected mode and write-files preference', async () => {
|
||||
const wrapper = mountModal()
|
||||
const radios = wrapper.findAll('input[type="radio"]')
|
||||
|
||||
await radios.find((radio) => radio.attributes('value') === 'hard')!.setValue()
|
||||
await wrapper.find('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').at(-1)!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual(['hard', true])
|
||||
})
|
||||
|
||||
it('emits cancel from the close button and backdrop', async () => {
|
||||
const wrapper = mountModal()
|
||||
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
await wrapper.find('.fixed').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
+142
-3
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { mount, shallowMount } from '@vue/test-utils'
|
||||
import { ENTITY_CAPABILITIES, type BrowseEntityItem } from '@bookorbit/types'
|
||||
|
||||
import EntityBrowseTable from '../EntityBrowseTable.vue'
|
||||
@@ -21,6 +21,9 @@ function mountTable(overrides: Partial<InstanceType<typeof EntityBrowseTable>['$
|
||||
pageSize: 25,
|
||||
totalPages: 1,
|
||||
search: '',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
bookCount: 'any',
|
||||
loading: false,
|
||||
selectedIds: new Set<number | string>(),
|
||||
capabilities: ENTITY_CAPABILITIES.author,
|
||||
@@ -30,10 +33,44 @@ function mountTable(overrides: Partial<InstanceType<typeof EntityBrowseTable>['$
|
||||
})
|
||||
}
|
||||
|
||||
function mountTableWithDropdown(overrides: Partial<InstanceType<typeof EntityBrowseTable>['$props']> = {}) {
|
||||
return mount(EntityBrowseTable, {
|
||||
props: {
|
||||
items: [makeItem(1)],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
totalPages: 1,
|
||||
search: '',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
bookCount: 'any',
|
||||
loading: false,
|
||||
selectedIds: new Set<number | string>(),
|
||||
capabilities: ENTITY_CAPABILITIES.author,
|
||||
isInline: false,
|
||||
...overrides,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
DropdownMenu: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { emits: ['click'], template: '<button class="dropdown-item" @click="$emit(\'click\')"><slot /></button>' },
|
||||
DropdownMenuSeparator: { template: '<hr />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('EntityBrowseTable', () => {
|
||||
function rowCheckboxes(wrapper: ReturnType<typeof mountTable>) {
|
||||
return wrapper.findAll('input[type="checkbox"]').slice(1)
|
||||
}
|
||||
|
||||
it('emits the checkbox click event so Shift-click selection can be handled by the parent', async () => {
|
||||
const wrapper = mountTable()
|
||||
const checkbox = wrapper.findAll('input[type="checkbox"]')[2]!
|
||||
const checkbox = rowCheckboxes(wrapper)[2]!
|
||||
|
||||
await checkbox.trigger('click', { shiftKey: true })
|
||||
|
||||
@@ -45,7 +82,7 @@ describe('EntityBrowseTable', () => {
|
||||
|
||||
it('still emits ordinary checkbox clicks without Shift pressed', async () => {
|
||||
const wrapper = mountTable()
|
||||
const checkbox = wrapper.findAll('input[type="checkbox"]')[0]!
|
||||
const checkbox = rowCheckboxes(wrapper)[0]!
|
||||
|
||||
await checkbox.trigger('click')
|
||||
|
||||
@@ -53,4 +90,106 @@ describe('EntityBrowseTable', () => {
|
||||
expect(selectEvent?.[0]).toBe(1)
|
||||
expect((selectEvent?.[1] as MouseEvent | undefined)?.shiftKey).toBe(false)
|
||||
})
|
||||
|
||||
it('emits sort updates when the sort selector changes', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
await wrapper.find('select').setValue('bookCount-asc')
|
||||
|
||||
expect(wrapper.emitted('sortChange')?.[0]).toEqual(['bookCount', 'asc'])
|
||||
})
|
||||
|
||||
it('emits all supported sort options from the sort selector', async () => {
|
||||
const wrapper = mountTable()
|
||||
const select = wrapper.find('select')
|
||||
|
||||
await select.setValue('name-desc')
|
||||
await select.setValue('bookCount-desc')
|
||||
await select.setValue('name-asc')
|
||||
|
||||
expect(wrapper.emitted('sortChange')).toEqual([
|
||||
['name', 'desc'],
|
||||
['bookCount', 'desc'],
|
||||
['name', 'asc'],
|
||||
])
|
||||
})
|
||||
|
||||
it('emits empty-only filter updates for first-class entities', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
const checkbox = wrapper.find('label input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
|
||||
expect(wrapper.emitted('update:bookCount')?.[0]).toEqual(['empty'])
|
||||
})
|
||||
|
||||
it('hides the empty-only filter for inline entity types', () => {
|
||||
const wrapper = mountTable({ isInline: true })
|
||||
|
||||
expect(wrapper.text()).not.toContain('Empty only')
|
||||
})
|
||||
|
||||
it('emits search updates from the search input', async () => {
|
||||
const wrapper = mountTable()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('tolkien')
|
||||
|
||||
expect(wrapper.emitted('update:search')?.[0]).toEqual(['tolkien'])
|
||||
})
|
||||
|
||||
it('emits previous and next page updates', async () => {
|
||||
const wrapper = mountTable({ items: [], total: 40, page: 2, totalPages: 3 })
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
await buttons[0]!.trigger('click')
|
||||
await buttons[1]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:page')).toEqual([[1], [3]])
|
||||
})
|
||||
|
||||
it('emits row action events', async () => {
|
||||
const item = makeItem(7)
|
||||
const wrapper = mountTable({ items: [item] })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
await buttons.find((button) => button.text() === 'Rename')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Split')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Delete')!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('rename')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('split')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('delete')?.[0]).toEqual([item])
|
||||
})
|
||||
|
||||
it('emits mobile dropdown action events', async () => {
|
||||
const item = makeItem(1)
|
||||
const wrapper = mountTableWithDropdown({ items: [item] })
|
||||
|
||||
const dropdownItems = wrapper.findAll('.dropdown-item')
|
||||
await dropdownItems[0]!.trigger('click')
|
||||
await dropdownItems[1]!.trigger('click')
|
||||
await dropdownItems[2]!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('rename')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('split')?.[0]).toEqual([item])
|
||||
expect(wrapper.emitted('delete')?.[0]).toEqual([item])
|
||||
})
|
||||
|
||||
it('emits bulk action and clear selection events', async () => {
|
||||
const wrapper = mountTable({ selectedIds: new Set([1, 2]) })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
await buttons.find((button) => button.text() === 'Clear')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Merge (2)')!.trigger('click')
|
||||
await buttons.find((button) => button.text() === 'Delete (2)')!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('clearSelection')).toHaveLength(1)
|
||||
expect(wrapper.emitted('bulkMerge')).toHaveLength(1)
|
||||
expect(wrapper.emitted('bulkDelete')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows loading and empty states', () => {
|
||||
expect(mountTable({ loading: true }).text()).toContain('Loading...')
|
||||
expect(mountTable({ items: [], total: 0 }).text()).toContain('No entities found')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { ENTITY_CAPABILITIES, type BrowseEntityItem } from '@bookorbit/types'
|
||||
|
||||
import EntityManagerView from './EntityManagerView.vue'
|
||||
@@ -57,6 +57,7 @@ function makeEntityManagerMock() {
|
||||
browseSearch: ref(''),
|
||||
browseSortBy: ref('name'),
|
||||
browseSortOrder: ref('asc'),
|
||||
browseBookCount: ref('any'),
|
||||
browseLoading: ref(false),
|
||||
browseTotalPages: ref(1),
|
||||
fetchBrowse: mockFn(),
|
||||
@@ -112,4 +113,198 @@ describe('EntityManagerView selection', () => {
|
||||
expect(entityManager.rangeSelectTo).toHaveBeenCalledWith(3)
|
||||
expect(entityManager.toggleSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets to the first page and refreshes when empty-only filtering changes', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.browsePage.value = 3
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
entityManager.fetchBrowse.mockClear()
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('update:bookCount', 'empty')
|
||||
|
||||
expect(entityManager.browseBookCount.value).toBe('empty')
|
||||
expect(entityManager.browsePage.value).toBe(1)
|
||||
await nextTick()
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refreshes immediately when empty-only filtering changes on the first page', () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
entityManager.fetchBrowse.mockClear()
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('update:bookCount', 'empty')
|
||||
|
||||
expect(entityManager.browseBookCount.value).toBe('empty')
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates browse sort atomically and refreshes once', () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
entityManager.fetchBrowse.mockClear()
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('sortChange', 'bookCount', 'desc')
|
||||
|
||||
expect(entityManager.browseSortBy.value).toBe('bookCount')
|
||||
expect(entityManager.browseSortOrder.value).toBe('desc')
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('defaults single zero-book deletes to hard delete', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('delete', { id: 7, name: 'Unused', bookCount: 0 })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'DeleteModal' }).props('defaultMode')).toBe('hard')
|
||||
})
|
||||
|
||||
it('keeps single non-empty deletes on soft delete by default', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('delete', { id: 7, name: 'Used', bookCount: 2 })
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'DeleteModal' }).props('defaultMode')).toBe('soft')
|
||||
})
|
||||
|
||||
it('defaults bulk deletes to hard only when all selected entities are empty', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.selectedIds.value = new Set([1, 2])
|
||||
entityManager.selectedItemsMap.value = new Map([
|
||||
[1, { id: 1, name: 'Unused 1', bookCount: 0 }],
|
||||
[2, { id: 2, name: 'Unused 2', bookCount: 0 }],
|
||||
])
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('bulkDelete')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'BulkDeleteModal' }).props('defaultMode')).toBe('hard')
|
||||
})
|
||||
|
||||
it('keeps mixed bulk deletes on soft delete by default', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.selectedIds.value = new Set([1, 2])
|
||||
entityManager.selectedItemsMap.value = new Map([
|
||||
[1, { id: 1, name: 'Unused', bookCount: 0 }],
|
||||
[2, { id: 2, name: 'Used', bookCount: 4 }],
|
||||
])
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('bulkDelete')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent({ name: 'BulkDeleteModal' }).props('defaultMode')).toBe('soft')
|
||||
})
|
||||
|
||||
it('forces empty-only updates back to any for inline entity types', () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.isInline.value = true
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
entityManager.fetchBrowse.mockClear()
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('update:bookCount', 'empty')
|
||||
|
||||
expect(entityManager.browseBookCount.value).toBe('any')
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renames a browsed entity and refreshes results', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
const item = { id: 4, name: 'Old Name', bookCount: 1 }
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('rename', item)
|
||||
await nextTick()
|
||||
wrapper.findComponent({ name: 'RenameModal' }).vm.$emit('confirm', 'New Name', true)
|
||||
await flushPromises()
|
||||
|
||||
expect(entityManager.renameEntity).toHaveBeenCalledWith(4, 'New Name', true)
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'RenameModal' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes a browsed entity, clears its selection, and refreshes results', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
const item = { id: 5, name: 'Unused', bookCount: 0 }
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('delete', item)
|
||||
await nextTick()
|
||||
wrapper.findComponent({ name: 'DeleteModal' }).vm.$emit('confirm', 'hard', true)
|
||||
await flushPromises()
|
||||
|
||||
expect(entityManager.deleteEntity).toHaveBeenCalledWith(5, 'hard', true)
|
||||
expect(entityManager.removeFromSelection).toHaveBeenCalledWith(5)
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'DeleteModal' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('splits a browsed entity, clears its selection, and refreshes results', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
const item = { id: 6, name: 'Combined', bookCount: 2 }
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('split', item)
|
||||
await nextTick()
|
||||
wrapper.findComponent({ name: 'SplitModal' }).vm.$emit('confirm', ['A', 'B'], false)
|
||||
await flushPromises()
|
||||
|
||||
expect(entityManager.splitEntity).toHaveBeenCalledWith(6, ['A', 'B'], false)
|
||||
expect(entityManager.removeFromSelection).toHaveBeenCalledWith(6)
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'SplitModal' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('bulk deletes selected entities, clears selection, and refreshes results', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.selectedIds.value = new Set([1, 2])
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('bulkDelete')
|
||||
await nextTick()
|
||||
wrapper.findComponent({ name: 'BulkDeleteModal' }).vm.$emit('confirm', 'soft', false)
|
||||
await flushPromises()
|
||||
|
||||
expect(entityManager.bulkDeleteEntities).toHaveBeenCalledWith([1, 2], 'soft', false)
|
||||
expect(entityManager.clearSelection).toHaveBeenCalled()
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'BulkDeleteModal' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('merges selected browse entities, clears selection, and refreshes results', async () => {
|
||||
const entityManager = makeEntityManagerMock()
|
||||
entityManager.selectedItemsMap.value = new Map([
|
||||
[1, { id: 1, name: 'Target', bookCount: 2 }],
|
||||
[2, { id: 2, name: 'Source', bookCount: 1 }],
|
||||
])
|
||||
mocks.useEntityManager.mockReturnValue(entityManager)
|
||||
const wrapper = shallowMount(EntityManagerView)
|
||||
|
||||
wrapper.findComponent({ name: 'EntityBrowseTable' }).vm.$emit('bulkMerge')
|
||||
await nextTick()
|
||||
wrapper.findComponent({ name: 'BrowseMergeModal' }).vm.$emit('confirm', 1, [2], true)
|
||||
await flushPromises()
|
||||
|
||||
expect(entityManager.mergeEntities).toHaveBeenCalledWith(1, [2], true)
|
||||
expect(entityManager.clearSelection).toHaveBeenCalled()
|
||||
expect(entityManager.fetchBrowse).toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'BrowseMergeModal' }).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,14 +25,25 @@ const showBulkDelete = ref(false)
|
||||
const showBrowseMerge = ref(false)
|
||||
|
||||
const selectedBrowseItems = computed(() => Array.from(em.selectedItemsMap.value.values()))
|
||||
const deleteDefaultMode = computed<'soft' | 'hard'>(() => (deleteTarget.value?.bookCount === 0 ? 'hard' : 'soft'))
|
||||
const bulkDeleteDefaultMode = computed<'soft' | 'hard'>(() =>
|
||||
selectedBrowseItems.value.length > 0 && selectedBrowseItems.value.every((item) => item.bookCount === 0) ? 'hard' : 'soft',
|
||||
)
|
||||
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function refreshBrowseFromFirstPage(): void {
|
||||
if (em.browsePage.value === 1) {
|
||||
em.fetchBrowse()
|
||||
return
|
||||
}
|
||||
em.browsePage.value = 1
|
||||
}
|
||||
|
||||
watch(em.browseSearch, () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => {
|
||||
em.browsePage.value = 1
|
||||
em.fetchBrowse()
|
||||
refreshBrowseFromFirstPage()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
@@ -102,6 +113,17 @@ function handleUpdatePage(value: number): void {
|
||||
em.browsePage.value = value
|
||||
}
|
||||
|
||||
function handleBrowseSortChange(sortBy: 'name' | 'bookCount', sortOrder: 'asc' | 'desc'): void {
|
||||
em.browseSortBy.value = sortBy
|
||||
em.browseSortOrder.value = sortOrder
|
||||
refreshBrowseFromFirstPage()
|
||||
}
|
||||
|
||||
function handleUpdateBookCount(value: 'any' | 'empty'): void {
|
||||
em.browseBookCount.value = em.isInline.value ? 'any' : value
|
||||
refreshBrowseFromFirstPage()
|
||||
}
|
||||
|
||||
async function handleMerge(targetId: number | string, sourceIds: (number | string)[], writeFiles: boolean): Promise<void> {
|
||||
await em.mergeEntities(targetId, sourceIds, writeFiles)
|
||||
em.removeClustersByIds(sourceIds)
|
||||
@@ -334,12 +356,17 @@ async function handleBulkDeleteConfirm(mode: 'soft' | 'hard' | 'inline', writeFi
|
||||
:page-size="em.browsePageSize.value"
|
||||
:total-pages="em.browseTotalPages.value"
|
||||
:search="em.browseSearch.value"
|
||||
:sort-by="em.browseSortBy.value"
|
||||
:sort-order="em.browseSortOrder.value"
|
||||
:book-count="em.browseBookCount.value"
|
||||
:loading="em.browseLoading.value"
|
||||
:selected-ids="em.selectedIds.value"
|
||||
:capabilities="em.capabilities.value"
|
||||
:is-inline="em.isInline.value"
|
||||
@update:page="handleUpdatePage"
|
||||
@update:search="handleUpdateSearch"
|
||||
@update:book-count="handleUpdateBookCount"
|
||||
@sort-change="handleBrowseSortChange"
|
||||
@select="handleSelectItem"
|
||||
@rename="handleRename"
|
||||
@delete="handleDelete"
|
||||
@@ -365,6 +392,7 @@ async function handleBulkDeleteConfirm(mode: 'soft' | 'hard' | 'inline', writeFi
|
||||
v-if="deleteTarget"
|
||||
:entity-name="deleteTarget.name"
|
||||
:is-inline="em.isInline.value"
|
||||
:default-mode="deleteDefaultMode"
|
||||
:loading="em.operationLoading.value"
|
||||
@confirm="handleDeleteConfirm"
|
||||
@cancel="deleteTarget = null"
|
||||
@@ -382,6 +410,7 @@ async function handleBulkDeleteConfirm(mode: 'soft' | 'hard' | 'inline', writeFi
|
||||
v-if="showBulkDelete"
|
||||
:count="em.selectedIds.value.size"
|
||||
:is-inline="em.isInline.value"
|
||||
:default-mode="bulkDeleteDefaultMode"
|
||||
:loading="em.operationLoading.value"
|
||||
@confirm="handleBulkDeleteConfirm"
|
||||
@cancel="showBulkDelete = false"
|
||||
|
||||
@@ -24,6 +24,19 @@ export const ENTITY_CAPABILITIES: Record<EntityType, EntityTypeCapabilities> = {
|
||||
};
|
||||
|
||||
// Browse
|
||||
export type BrowseEntitySortBy = "name" | "bookCount";
|
||||
export type BrowseEntitySortOrder = "asc" | "desc";
|
||||
export type BrowseEntityBookCountFilter = "any" | "empty";
|
||||
|
||||
export interface BrowseEntitiesParams {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: BrowseEntitySortBy;
|
||||
sortOrder?: BrowseEntitySortOrder;
|
||||
bookCount?: BrowseEntityBookCountFilter;
|
||||
}
|
||||
|
||||
export interface BrowseEntityItem {
|
||||
id: number | string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { BrowseEntitiesDto } from './entity-manager.dto';
|
||||
|
||||
describe('BrowseEntitiesDto', () => {
|
||||
it('accepts empty book count filtering', async () => {
|
||||
const dto = plainToInstance(BrowseEntitiesDto, { bookCount: 'empty' });
|
||||
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects invalid book count filtering', async () => {
|
||||
const dto = plainToInstance(BrowseEntitiesDto, { bookCount: 'zero' });
|
||||
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors).toEqual(expect.arrayContaining([expect.objectContaining({ property: 'bookCount' })]));
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,10 @@ export class BrowseEntitiesDto {
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
sortOrder?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['any', 'empty'])
|
||||
bookCount?: string;
|
||||
}
|
||||
|
||||
export class MergeEntitiesDto {
|
||||
|
||||
@@ -524,6 +524,7 @@ describe('EntityManagerService', () => {
|
||||
pageSize: 25,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
bookCount: 'any',
|
||||
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
||||
});
|
||||
expect(result.items).toHaveLength(1);
|
||||
@@ -536,7 +537,14 @@ describe('EntityManagerService', () => {
|
||||
const { service, strategies } = makeService();
|
||||
(strategies.tag.browse as any).mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
await service.browse('tag', mockUser, { search: 'fic', page: 2, pageSize: 50, sortBy: 'bookCount', sortOrder: 'desc' });
|
||||
await service.browse('tag', mockUser, {
|
||||
search: 'fic',
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
sortBy: 'bookCount',
|
||||
sortOrder: 'desc',
|
||||
bookCount: 'empty',
|
||||
});
|
||||
|
||||
expect(strategies.tag.browse).toHaveBeenCalledWith({
|
||||
libraryIds: [1, 2],
|
||||
@@ -545,9 +553,19 @@ describe('EntityManagerService', () => {
|
||||
pageSize: 50,
|
||||
sortBy: 'bookCount',
|
||||
sortOrder: 'desc',
|
||||
bookCount: 'empty',
|
||||
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not pass empty book count filtering to inline strategies', async () => {
|
||||
const { service, strategies } = makeService();
|
||||
(strategies.publisher.browse as any).mockResolvedValue({ items: [], total: 0 });
|
||||
|
||||
await service.browse('publisher', mockUser, { bookCount: 'empty' });
|
||||
|
||||
expect(strategies.publisher.browse).toHaveBeenCalledWith(expect.objectContaining({ bookCount: 'any' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge', () => {
|
||||
@@ -668,12 +686,27 @@ describe('EntityManagerService', () => {
|
||||
it('does not clean dismissed pairs for soft delete', async () => {
|
||||
const { service, strategies, repo } = makeService();
|
||||
(strategies.genre.deleteEntity as any).mockResolvedValue({ name: 'Fantasy', affectedBookIds: [] });
|
||||
(strategies.genre.getBookCount as any).mockResolvedValue(2);
|
||||
|
||||
await service.deleteEntity('genre', mockUser, 5, 'soft', false);
|
||||
|
||||
expect(strategies.genre.deleteEntity).toHaveBeenCalledWith({ entityId: 5, mode: 'soft', libraryIds: [1, 2] });
|
||||
expect(repo.deleteDismissedPairsForEntity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses hard delete for zero-book first-class entities requested as soft delete', async () => {
|
||||
const { service, strategies, repo, duplicateCompute } = makeService();
|
||||
(strategies.author.deleteEntity as any).mockResolvedValue({ name: 'Unused', affectedBookIds: [] });
|
||||
(strategies.author.getBookCount as any).mockResolvedValue(0);
|
||||
|
||||
const result = await service.deleteEntity('author', mockUser, 5, 'soft', false);
|
||||
|
||||
expect(strategies.author.deleteEntity).toHaveBeenCalledWith({ entityId: 5, mode: 'hard', libraryIds: [1, 2] });
|
||||
expect(repo.deleteDismissedPairsForEntity).toHaveBeenCalledWith('author', 5);
|
||||
expect(duplicateCompute.invalidateCandidatesForEntities).toHaveBeenCalledWith('author', [5]);
|
||||
expect(result.mode).toBe('hard');
|
||||
});
|
||||
|
||||
it('cleans inline dismissed pairs for inline entities', async () => {
|
||||
const { service, strategies, repo } = makeService();
|
||||
(strategies.language.deleteEntity as any).mockResolvedValue({ name: 'English', affectedBookIds: [3] });
|
||||
|
||||
@@ -203,7 +203,7 @@ export class EntityManagerService {
|
||||
async browse(
|
||||
entityType: EntityType,
|
||||
user: RequestUser,
|
||||
params: { search?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: string },
|
||||
params: { search?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: string; bookCount?: string },
|
||||
): Promise<BrowseEntitiesResponse> {
|
||||
const strategy = this.getStrategy(entityType);
|
||||
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
|
||||
@@ -215,6 +215,7 @@ export class EntityManagerService {
|
||||
pageSize: params.pageSize ?? 25,
|
||||
sortBy: (params.sortBy as 'name' | 'bookCount') || 'name',
|
||||
sortOrder: (params.sortOrder as 'asc' | 'desc') || 'asc',
|
||||
bookCount: strategy.isInline ? 'any' : (params.bookCount as 'any' | 'empty') || 'any',
|
||||
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
|
||||
});
|
||||
|
||||
@@ -326,11 +327,12 @@ export class EntityManagerService {
|
||||
try {
|
||||
const strategy = this.getStrategy(entityType);
|
||||
const libraryIds = await this.libraryService.findAccessibleLibraryIds(user);
|
||||
const result = await strategy.deleteEntity({ entityId, mode, libraryIds });
|
||||
const effectiveMode = !strategy.isInline && mode === 'soft' && (await strategy.getBookCount(entityId)) === 0 ? 'hard' : mode;
|
||||
const result = await strategy.deleteEntity({ entityId, mode: effectiveMode, libraryIds });
|
||||
|
||||
if (strategy.isInline) {
|
||||
await this.repo.deleteInlineDismissedPairsForValue(entityType, entityId as string);
|
||||
} else if (mode === 'hard') {
|
||||
} else if (effectiveMode === 'hard') {
|
||||
await this.repo.deleteDismissedPairsForEntity(entityType, entityId as number);
|
||||
await this.duplicateCompute.invalidateCandidatesForEntities(entityType, [entityId as number]);
|
||||
}
|
||||
@@ -343,7 +345,7 @@ export class EntityManagerService {
|
||||
`[${event}] [end] userId=${user.id} entityType=${entityType} durationMs=${Date.now() - startedAt} affectedBooks=${result.affectedBookIds.length} - delete completed`,
|
||||
);
|
||||
|
||||
return { entityId, name: result.name, affectedBookCount: result.affectedBookIds.length, mode };
|
||||
return { entityId, name: result.name, affectedBookCount: result.affectedBookIds.length, mode: effectiveMode };
|
||||
} catch (err) {
|
||||
const error = logErrorFields(err);
|
||||
this.logger.warn(
|
||||
|
||||
@@ -37,6 +37,11 @@ function makeSelectChain(rows: unknown[]) {
|
||||
}
|
||||
|
||||
describe('AuthorStrategy', () => {
|
||||
function thenableRows(rows: unknown[]) {
|
||||
const promise = Promise.resolve(rows);
|
||||
return { then: promise.then.bind(promise) };
|
||||
}
|
||||
|
||||
describe('findCandidatePairs', () => {
|
||||
it('runs two executes inside a transaction', async () => {
|
||||
const rows = [{ idA: 1, idB: 2, nameA: 'Tolkien', nameB: 'Tolkein', simScore: 0.9 }];
|
||||
@@ -123,7 +128,9 @@ describe('AuthorStrategy', () => {
|
||||
const itemOffset = vi.fn().mockResolvedValue(itemRows);
|
||||
const itemLimit = vi.fn().mockReturnValue({ offset: itemOffset });
|
||||
const itemOrderBy = vi.fn().mockReturnValue({ limit: itemLimit });
|
||||
const itemGroupBy = vi.fn().mockReturnValue({ orderBy: itemOrderBy });
|
||||
const itemDynamic = { having: vi.fn(), orderBy: itemOrderBy, ...thenableRows(itemRows) };
|
||||
const itemDynamicFactory = vi.fn().mockReturnValue(itemDynamic);
|
||||
const itemGroupBy = vi.fn().mockReturnValue({ $dynamic: itemDynamicFactory });
|
||||
const itemWhere = vi.fn().mockReturnValue({ groupBy: itemGroupBy });
|
||||
const itemLeftJoin = vi.fn().mockReturnValue({ where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ leftJoin: itemLeftJoin, where: itemWhere });
|
||||
@@ -131,11 +138,17 @@ describe('AuthorStrategy', () => {
|
||||
const subqueryWhere = vi.fn().mockReturnValue('SUBQUERY_PLACEHOLDER');
|
||||
const subqueryFrom = vi.fn().mockReturnValue({ where: subqueryWhere });
|
||||
|
||||
const relationWhere = vi.fn().mockReturnValue({ _isRelationSubquery: true });
|
||||
const relationFrom = vi.fn().mockReturnValue({ where: relationWhere });
|
||||
|
||||
let callCount = 0;
|
||||
const select = vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return { from: countFrom };
|
||||
if (callCount === 2 && hasLibraryIds) return { from: subqueryFrom };
|
||||
if (callCount === 1 && hasLibraryIds) return { from: subqueryFrom };
|
||||
if ((hasLibraryIds && (callCount === 2 || callCount === 3)) || (!hasLibraryIds && callCount === 1)) {
|
||||
return { from: relationFrom };
|
||||
}
|
||||
if ((callCount === 2 && !hasLibraryIds) || (callCount === 4 && hasLibraryIds)) return { from: countFrom };
|
||||
return { from: itemFrom };
|
||||
});
|
||||
|
||||
@@ -150,39 +163,39 @@ describe('AuthorStrategy', () => {
|
||||
const { select } = makeBrowseDb([{ total: 2 }], itemRows, false);
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
const result = await strategy.browse({ libraryIds: [], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc' });
|
||||
const result = await strategy.browse({ libraryIds: [], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' });
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]).toMatchObject({ id: 1, name: 'Tolkien', bookCount: 5 });
|
||||
expect(select).toHaveBeenCalledTimes(2);
|
||||
expect(select).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('uses a subquery leftJoin condition when libraryIds are provided', async () => {
|
||||
const { select, itemLeftJoin } = makeBrowseDb([{ total: 0 }], [], true);
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
await strategy.browse({ libraryIds: [5], page: 1, pageSize: 10, sortBy: 'name', sortOrder: 'asc' });
|
||||
await strategy.browse({ libraryIds: [5], page: 1, pageSize: 10, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' });
|
||||
|
||||
expect(itemLeftJoin).toHaveBeenCalledTimes(1);
|
||||
expect(select).toHaveBeenCalledTimes(3);
|
||||
expect(select).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('joins without a subquery filter when libraryIds is empty', async () => {
|
||||
const { select, itemLeftJoin } = makeBrowseDb([{ total: 0 }], [], false);
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
await strategy.browse({ libraryIds: [], page: 1, pageSize: 10, sortBy: 'name', sortOrder: 'asc' });
|
||||
await strategy.browse({ libraryIds: [], page: 1, pageSize: 10, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' });
|
||||
|
||||
expect(itemLeftJoin).toHaveBeenCalledTimes(1);
|
||||
expect(select).toHaveBeenCalledTimes(2);
|
||||
expect(select).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns zero total when countResult is empty', async () => {
|
||||
const { select } = makeBrowseDb([], [], false);
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
const result = await strategy.browse({ libraryIds: [], page: 1, pageSize: 10, sortBy: 'name', sortOrder: 'asc' });
|
||||
const result = await strategy.browse({ libraryIds: [], page: 1, pageSize: 10, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' });
|
||||
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, asc, count, desc, eq, ilike, inArray, sql } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, exists, ilike, inArray, notExists, or, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db';
|
||||
@@ -142,40 +142,60 @@ export class AuthorStrategy implements EntityStrategy {
|
||||
.where(and(inArray(books.libraryId, params.libraryIds), ...cfClauses))
|
||||
: null;
|
||||
|
||||
const [countResult, itemRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ total: sql<number>`count(distinct ${authors.id})::int` })
|
||||
.from(authors)
|
||||
.where(nameCondition),
|
||||
this.db
|
||||
.select({
|
||||
id: authors.id,
|
||||
name: authors.name,
|
||||
sortName: authors.sortName,
|
||||
hasPhoto: authors.hasPhoto,
|
||||
bookCount: bookCountExpr,
|
||||
})
|
||||
.from(authors)
|
||||
.leftJoin(
|
||||
bookAuthors,
|
||||
libraryBookIds
|
||||
? and(eq(bookAuthors.authorId, authors.id), inArray(bookAuthors.bookId, libraryBookIds))
|
||||
: eq(bookAuthors.authorId, authors.id),
|
||||
const joinCondition = libraryBookIds
|
||||
? and(eq(bookAuthors.authorId, authors.id), inArray(bookAuthors.bookId, libraryBookIds))
|
||||
: and(eq(bookAuthors.authorId, authors.id), sql`false`);
|
||||
|
||||
const hasScopedBooks = libraryBookIds
|
||||
? exists(
|
||||
this.db
|
||||
.select({ one: sql`1` })
|
||||
.from(bookAuthors)
|
||||
.where(and(eq(bookAuthors.authorId, authors.id), inArray(bookAuthors.bookId, libraryBookIds))),
|
||||
)
|
||||
.where(nameCondition)
|
||||
.groupBy(authors.id, authors.name, authors.sortName, authors.hasPhoto)
|
||||
.orderBy(
|
||||
params.sortBy === 'bookCount'
|
||||
? params.sortOrder === 'asc'
|
||||
? asc(bookCountExpr)
|
||||
: desc(bookCountExpr)
|
||||
: params.sortOrder === 'asc'
|
||||
? asc(authors.name)
|
||||
: desc(authors.name),
|
||||
)
|
||||
.limit(params.pageSize)
|
||||
.offset((params.page - 1) * params.pageSize),
|
||||
]);
|
||||
: undefined;
|
||||
const isGloballyEmpty = notExists(
|
||||
this.db
|
||||
.select({ one: sql`1` })
|
||||
.from(bookAuthors)
|
||||
.where(eq(bookAuthors.authorId, authors.id)),
|
||||
);
|
||||
const visibilityCondition =
|
||||
params.bookCount === 'empty' ? isGloballyEmpty : hasScopedBooks ? or(hasScopedBooks, isGloballyEmpty) : isGloballyEmpty;
|
||||
|
||||
const countQuery = this.db
|
||||
.select({ total: sql<number>`count(distinct ${authors.id})::int` })
|
||||
.from(authors)
|
||||
.where(and(nameCondition, visibilityCondition));
|
||||
|
||||
const itemQuery = this.db
|
||||
.select({
|
||||
id: authors.id,
|
||||
name: authors.name,
|
||||
sortName: authors.sortName,
|
||||
hasPhoto: authors.hasPhoto,
|
||||
bookCount: bookCountExpr,
|
||||
})
|
||||
.from(authors)
|
||||
.leftJoin(bookAuthors, joinCondition)
|
||||
.where(and(nameCondition, visibilityCondition))
|
||||
.groupBy(authors.id, authors.name, authors.sortName, authors.hasPhoto)
|
||||
.$dynamic();
|
||||
|
||||
itemQuery
|
||||
.orderBy(
|
||||
params.sortBy === 'bookCount'
|
||||
? params.sortOrder === 'asc'
|
||||
? asc(bookCountExpr)
|
||||
: desc(bookCountExpr)
|
||||
: params.sortOrder === 'asc'
|
||||
? asc(authors.name)
|
||||
: desc(authors.name),
|
||||
)
|
||||
.limit(params.pageSize)
|
||||
.offset((params.page - 1) * params.pageSize);
|
||||
|
||||
const [countResult, itemRows] = await Promise.all([countQuery, itemQuery]);
|
||||
|
||||
return {
|
||||
items: itemRows.map((r) => ({ id: r.id, name: r.name, bookCount: r.bookCount, sortName: r.sortName, hasPhoto: r.hasPhoto })),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EntityType } from '@bookorbit/types';
|
||||
import type { BrowseEntityBookCountFilter, BrowseEntitySortBy, BrowseEntitySortOrder, EntityType } from '@bookorbit/types';
|
||||
|
||||
export interface RawCandidatePair {
|
||||
idA: number | string;
|
||||
@@ -17,8 +17,9 @@ export interface BrowseParams {
|
||||
search?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortBy: 'name' | 'bookCount';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
sortBy: BrowseEntitySortBy;
|
||||
sortOrder: BrowseEntitySortOrder;
|
||||
bookCount: BrowseEntityBookCountFilter;
|
||||
contentFilters?: import('@bookorbit/types').ContentFilterRules;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('InlineEntityStrategy', () => {
|
||||
.mockResolvedValueOnce({ rows: [{ name: 'Alice', bookCount: 3 }] });
|
||||
const strategy = makeStrategy({ execute });
|
||||
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc' });
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' });
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ items: [{ id: 'Alice', name: 'Alice', bookCount: 3 }], total: 2 });
|
||||
@@ -84,7 +84,7 @@ describe('InlineEntityStrategy', () => {
|
||||
const execute = vi.fn().mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] });
|
||||
const strategy = makeStrategy({ execute });
|
||||
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc' });
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' });
|
||||
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.items).toEqual([]);
|
||||
|
||||
@@ -78,6 +78,11 @@ function makeTransactionMock(txOverrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe('TagStrategy (JunctionEntityStrategy)', () => {
|
||||
function thenableRows(rows: unknown[]) {
|
||||
const promise = Promise.resolve(rows);
|
||||
return { then: promise.then.bind(promise) };
|
||||
}
|
||||
|
||||
describe('findCandidatePairs', () => {
|
||||
it('runs inside a transaction and executes two SQL statements', async () => {
|
||||
const rows = [{ idA: 1, idB: 2, nameA: 'Fantasy', nameB: 'Fantasi', simScore: 0.9 }];
|
||||
@@ -143,31 +148,40 @@ describe('TagStrategy (JunctionEntityStrategy)', () => {
|
||||
const subqueryFrom = vi.fn().mockReturnValue({ where: subqueryWhere });
|
||||
const subquerySelect = { from: subqueryFrom };
|
||||
|
||||
// Second select call: count query
|
||||
const relationWhere = vi.fn().mockReturnValue({ _isRelationSubquery: true });
|
||||
const relationFrom = vi.fn().mockReturnValue({ where: relationWhere });
|
||||
const relationSelect = { from: relationFrom };
|
||||
|
||||
// Fourth select call: count query
|
||||
const countWhere = vi.fn().mockResolvedValue([{ total: 1 }]);
|
||||
const countInnerJoin = vi.fn();
|
||||
countInnerJoin.mockReturnValue({ innerJoin: countInnerJoin, where: countWhere });
|
||||
const countFrom = vi.fn().mockReturnValue({ innerJoin: countInnerJoin });
|
||||
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
|
||||
const countSelect = { from: countFrom };
|
||||
|
||||
// Third select call: items query
|
||||
// Fifth select call: items query
|
||||
const offset = vi.fn().mockResolvedValue([{ id: 5, name: 'Fantasy', bookCount: 12 }]);
|
||||
const limit = vi.fn().mockReturnValue({ offset });
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const groupBy = vi.fn().mockReturnValue({ orderBy });
|
||||
const dynamic = { having: vi.fn(), orderBy, ...thenableRows([{ id: 5, name: 'Fantasy', bookCount: 12 }]) };
|
||||
const groupBy = vi.fn().mockReturnValue({ $dynamic: vi.fn().mockReturnValue(dynamic) });
|
||||
const itemWhere = vi.fn().mockReturnValue({ groupBy });
|
||||
const itemInnerJoin = vi.fn();
|
||||
itemInnerJoin.mockReturnValue({ innerJoin: itemInnerJoin, where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ innerJoin: itemInnerJoin });
|
||||
const itemLeftJoin = vi.fn().mockReturnValue({ where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ leftJoin: itemLeftJoin });
|
||||
const itemSelect = { from: itemFrom };
|
||||
|
||||
const select = vi.fn().mockReturnValueOnce(subquerySelect).mockReturnValueOnce(countSelect).mockReturnValueOnce(itemSelect);
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(subquerySelect)
|
||||
.mockReturnValueOnce(relationSelect)
|
||||
.mockReturnValueOnce(relationSelect)
|
||||
.mockReturnValueOnce(countSelect)
|
||||
.mockReturnValueOnce(itemSelect);
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'bookCount', sortOrder: 'desc' });
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'bookCount', sortOrder: 'desc', bookCount: 'any' });
|
||||
|
||||
expect(result).toEqual({ items: [{ id: 5, name: 'Fantasy', bookCount: 12 }], total: 1 });
|
||||
expect(select).toHaveBeenCalledTimes(3);
|
||||
expect(select).toHaveBeenCalledTimes(5);
|
||||
expect(itemLeftJoin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 0 total when count row is missing', async () => {
|
||||
@@ -175,24 +189,34 @@ describe('TagStrategy (JunctionEntityStrategy)', () => {
|
||||
const subqueryFrom = vi.fn().mockReturnValue({ where: subqueryWhere });
|
||||
const subquerySelect = { from: subqueryFrom };
|
||||
|
||||
const relationWhere = vi.fn().mockReturnValue({ _isRelationSubquery: true });
|
||||
const relationFrom = vi.fn().mockReturnValue({ where: relationWhere });
|
||||
const relationSelect = { from: relationFrom };
|
||||
|
||||
const countWhere = vi.fn().mockResolvedValue([]);
|
||||
const countInnerJoin = vi.fn().mockReturnValue({ where: countWhere });
|
||||
const countFrom = vi.fn().mockReturnValue({ innerJoin: countInnerJoin });
|
||||
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
|
||||
const countSelect = { from: countFrom };
|
||||
|
||||
const offset = vi.fn().mockResolvedValue([]);
|
||||
const limit = vi.fn().mockReturnValue({ offset });
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const groupBy = vi.fn().mockReturnValue({ orderBy });
|
||||
const dynamic = { having: vi.fn(), orderBy, ...thenableRows([]) };
|
||||
const groupBy = vi.fn().mockReturnValue({ $dynamic: vi.fn().mockReturnValue(dynamic) });
|
||||
const itemWhere = vi.fn().mockReturnValue({ groupBy });
|
||||
const itemInnerJoin = vi.fn().mockReturnValue({ where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ innerJoin: itemInnerJoin });
|
||||
const itemLeftJoin = vi.fn().mockReturnValue({ where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ leftJoin: itemLeftJoin });
|
||||
const itemSelect = { from: itemFrom };
|
||||
|
||||
const select = vi.fn().mockReturnValueOnce(subquerySelect).mockReturnValueOnce(countSelect).mockReturnValueOnce(itemSelect);
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(subquerySelect)
|
||||
.mockReturnValueOnce(relationSelect)
|
||||
.mockReturnValueOnce(relationSelect)
|
||||
.mockReturnValueOnce(countSelect)
|
||||
.mockReturnValueOnce(itemSelect);
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc' });
|
||||
const result = await strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc', bookCount: 'any' });
|
||||
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { and, asc, count, desc, eq, ilike, inArray, sql } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, exists, ilike, inArray, notExists, or, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import type { PgColumn, PgTableWithColumns } from 'drizzle-orm/pg-core';
|
||||
|
||||
@@ -124,43 +124,64 @@ export abstract class JunctionEntityStrategy implements EntityStrategy {
|
||||
|
||||
async browse(params: BrowseParams): Promise<BrowseResult> {
|
||||
const cfClauses = params.contentFilters ? buildContentFilterClauses(params.contentFilters, this.db) : [];
|
||||
const bookSubquery = this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.where(and(inArray(books.libraryId, params.libraryIds), ...cfClauses));
|
||||
const bookSubquery =
|
||||
params.libraryIds.length > 0
|
||||
? this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.where(and(inArray(books.libraryId, params.libraryIds), ...cfClauses))
|
||||
: null;
|
||||
|
||||
const conditions = [inArray(this.junctionBookIdCol, bookSubquery)];
|
||||
if (params.search) {
|
||||
conditions.push(ilike(this.nameCol, `%${escapeLike(params.search)}%`) as any);
|
||||
}
|
||||
|
||||
const where = and(...conditions);
|
||||
const nameCondition = params.search ? (ilike(this.nameCol, `%${escapeLike(params.search)}%`) as any) : undefined;
|
||||
const joinCondition = bookSubquery
|
||||
? and(eq(this.junctionEntityIdCol, this.entityIdCol), inArray(this.junctionBookIdCol, bookSubquery))
|
||||
: and(eq(this.junctionEntityIdCol, this.entityIdCol), sql`false`);
|
||||
const bookCountExpr = sql<number>`count(distinct ${this.junctionBookIdCol})::int`;
|
||||
|
||||
const [countResult, itemRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ total: sql<number>`count(distinct ${this.entityIdCol})::int` })
|
||||
.from(this.entityTable)
|
||||
.innerJoin(this.junctionTable, eq(this.junctionEntityIdCol, this.entityIdCol))
|
||||
.where(where),
|
||||
this.db
|
||||
.select({ id: this.entityIdCol, name: this.nameCol, bookCount: bookCountExpr })
|
||||
.from(this.entityTable)
|
||||
.innerJoin(this.junctionTable, eq(this.junctionEntityIdCol, this.entityIdCol))
|
||||
.where(where)
|
||||
.groupBy(this.entityIdCol, this.nameCol)
|
||||
.orderBy(
|
||||
params.sortBy === 'bookCount'
|
||||
? params.sortOrder === 'asc'
|
||||
? asc(bookCountExpr)
|
||||
: desc(bookCountExpr)
|
||||
: params.sortOrder === 'asc'
|
||||
? asc(this.nameCol)
|
||||
: desc(this.nameCol),
|
||||
const hasScopedBooks = bookSubquery
|
||||
? exists(
|
||||
this.db
|
||||
.select({ one: sql`1` })
|
||||
.from(this.junctionTable)
|
||||
.where(and(eq(this.junctionEntityIdCol, this.entityIdCol), inArray(this.junctionBookIdCol, bookSubquery))),
|
||||
)
|
||||
.limit(params.pageSize)
|
||||
.offset((params.page - 1) * params.pageSize),
|
||||
]);
|
||||
: undefined;
|
||||
const isGloballyEmpty = notExists(
|
||||
this.db
|
||||
.select({ one: sql`1` })
|
||||
.from(this.junctionTable)
|
||||
.where(eq(this.junctionEntityIdCol, this.entityIdCol)),
|
||||
);
|
||||
const visibilityCondition =
|
||||
params.bookCount === 'empty' ? isGloballyEmpty : hasScopedBooks ? or(hasScopedBooks, isGloballyEmpty) : isGloballyEmpty;
|
||||
|
||||
const countQuery = this.db
|
||||
.select({ total: sql<number>`count(distinct ${this.entityIdCol})::int` })
|
||||
.from(this.entityTable)
|
||||
.where(and(nameCondition, visibilityCondition));
|
||||
|
||||
const itemQuery = this.db
|
||||
.select({ id: this.entityIdCol, name: this.nameCol, bookCount: bookCountExpr })
|
||||
.from(this.entityTable)
|
||||
.leftJoin(this.junctionTable, joinCondition)
|
||||
.where(and(nameCondition, visibilityCondition))
|
||||
.groupBy(this.entityIdCol, this.nameCol)
|
||||
.$dynamic();
|
||||
|
||||
itemQuery
|
||||
.orderBy(
|
||||
params.sortBy === 'bookCount'
|
||||
? params.sortOrder === 'asc'
|
||||
? asc(bookCountExpr)
|
||||
: desc(bookCountExpr)
|
||||
: params.sortOrder === 'asc'
|
||||
? asc(this.nameCol)
|
||||
: desc(this.nameCol),
|
||||
)
|
||||
.limit(params.pageSize)
|
||||
.offset((params.page - 1) * params.pageSize);
|
||||
|
||||
const [countResult, itemRows] = await Promise.all([countQuery, itemQuery]);
|
||||
|
||||
return {
|
||||
items: itemRows.map((r: any) => ({ id: r.id, name: r.name, bookCount: r.bookCount })),
|
||||
|
||||
@@ -29,9 +29,10 @@ function makeSelectChain(rows: unknown[]) {
|
||||
const where = vi.fn().mockReturnValue({ orderBy, limit });
|
||||
const innerJoin = vi.fn();
|
||||
innerJoin.mockReturnValue({ where, innerJoin });
|
||||
const from = vi.fn().mockReturnValue({ where, innerJoin });
|
||||
const leftJoin = vi.fn().mockReturnValue({ where });
|
||||
const from = vi.fn().mockReturnValue({ where, innerJoin, leftJoin });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
return { select, from, innerJoin, where, orderBy, limit };
|
||||
return { select, from, innerJoin, leftJoin, where, orderBy, limit };
|
||||
}
|
||||
|
||||
function makeWhereRowsSelectChain(rows: unknown[]) {
|
||||
@@ -56,6 +57,11 @@ function makeUpdateTx() {
|
||||
}
|
||||
|
||||
describe('SeriesStrategy', () => {
|
||||
function thenableRows(rows: unknown[]) {
|
||||
const promise = Promise.resolve(rows);
|
||||
return { then: promise.then.bind(promise) };
|
||||
}
|
||||
|
||||
it('does not compute candidate pairs without a library scope', async () => {
|
||||
const transaction = vi.fn();
|
||||
const strategy = makeStrategy({ transaction });
|
||||
@@ -125,40 +131,87 @@ describe('SeriesStrategy', () => {
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('browses table-backed series within accessible libraries', async () => {
|
||||
it('browses table-backed series with left joined memberships so empty rows can appear', async () => {
|
||||
const subqueryWhere = vi.fn().mockReturnValue({ _isSubquery: true });
|
||||
const subqueryFrom = vi.fn().mockReturnValue({ where: subqueryWhere });
|
||||
|
||||
const relationWhere = vi.fn().mockReturnValue({ _isRelationSubquery: true });
|
||||
const relationFrom = vi.fn().mockReturnValue({ where: relationWhere });
|
||||
|
||||
const countWhere = vi.fn().mockResolvedValue([{ total: 1 }]);
|
||||
const countInnerJoin = vi.fn();
|
||||
countInnerJoin.mockReturnValue({ innerJoin: countInnerJoin, where: countWhere });
|
||||
const countFrom = vi.fn().mockReturnValue({ innerJoin: countInnerJoin });
|
||||
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
|
||||
|
||||
const offset = vi.fn().mockResolvedValue([{ id: 42, name: 'Dune', bookCount: 6 }]);
|
||||
const limit = vi.fn().mockReturnValue({ offset });
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const groupBy = vi.fn().mockReturnValue({ orderBy });
|
||||
const dynamic = { having: vi.fn(), orderBy, ...thenableRows([{ id: 42, name: 'Dune', bookCount: 6 }]) };
|
||||
const groupBy = vi.fn().mockReturnValue({ $dynamic: vi.fn().mockReturnValue(dynamic) });
|
||||
const itemWhere = vi.fn().mockReturnValue({ groupBy });
|
||||
const itemInnerJoin = vi.fn();
|
||||
itemInnerJoin.mockReturnValue({ innerJoin: itemInnerJoin, where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ innerJoin: itemInnerJoin });
|
||||
const itemLeftJoin = vi.fn().mockReturnValue({ where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ leftJoin: itemLeftJoin });
|
||||
|
||||
const select = vi.fn().mockReturnValueOnce({ from: countFrom }).mockReturnValueOnce({ from: itemFrom });
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ from: subqueryFrom })
|
||||
.mockReturnValueOnce({ from: relationFrom })
|
||||
.mockReturnValueOnce({ from: relationFrom })
|
||||
.mockReturnValueOnce({ from: countFrom })
|
||||
.mockReturnValueOnce({ from: itemFrom });
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
const result = await strategy.browse({ libraryIds: [1], search: 'Dune', page: 1, pageSize: 25, sortBy: 'bookCount', sortOrder: 'desc' });
|
||||
|
||||
expect(result).toEqual({ items: [{ id: 42, name: 'Dune', bookCount: 6 }], total: 1 });
|
||||
expect(select).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns an empty browse result without library access', async () => {
|
||||
const select = vi.fn();
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
await expect(strategy.browse({ libraryIds: [], page: 1, pageSize: 25, sortBy: 'name', sortOrder: 'asc' })).resolves.toEqual({
|
||||
items: [],
|
||||
total: 0,
|
||||
const result = await strategy.browse({
|
||||
libraryIds: [1],
|
||||
search: 'Dune',
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
sortBy: 'bookCount',
|
||||
sortOrder: 'desc',
|
||||
bookCount: 'any',
|
||||
});
|
||||
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ items: [{ id: 42, name: 'Dune', bookCount: 6 }], total: 1 });
|
||||
expect(select).toHaveBeenCalledTimes(5);
|
||||
expect(relationWhere).toHaveBeenCalledTimes(2);
|
||||
expect(itemLeftJoin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns zero-book series when filtering to empty entities', async () => {
|
||||
const subqueryWhere = vi.fn().mockReturnValue({ _isSubquery: true });
|
||||
const subqueryFrom = vi.fn().mockReturnValue({ where: subqueryWhere });
|
||||
|
||||
const relationWhere = vi.fn().mockReturnValue({ _isRelationSubquery: true });
|
||||
const relationFrom = vi.fn().mockReturnValue({ where: relationWhere });
|
||||
|
||||
const countWhere = vi.fn().mockResolvedValue([{ total: 1 }]);
|
||||
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
|
||||
|
||||
const offset = vi.fn().mockResolvedValue([{ id: 99, name: 'Unused', bookCount: 0 }]);
|
||||
const limit = vi.fn().mockReturnValue({ offset });
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const dynamic = { having: vi.fn(), orderBy, ...thenableRows([{ id: 99, name: 'Unused', bookCount: 0 }]) };
|
||||
const groupBy = vi.fn().mockReturnValue({ $dynamic: vi.fn().mockReturnValue(dynamic) });
|
||||
const itemWhere = vi.fn().mockReturnValue({ groupBy });
|
||||
const itemLeftJoin = vi.fn().mockReturnValue({ where: itemWhere });
|
||||
const itemFrom = vi.fn().mockReturnValue({ leftJoin: itemLeftJoin });
|
||||
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ from: subqueryFrom })
|
||||
.mockReturnValueOnce({ from: relationFrom })
|
||||
.mockReturnValueOnce({ from: relationFrom })
|
||||
.mockReturnValueOnce({ from: countFrom })
|
||||
.mockReturnValueOnce({ from: itemFrom });
|
||||
const strategy = makeStrategy({ select });
|
||||
|
||||
await expect(
|
||||
strategy.browse({ libraryIds: [1], page: 1, pageSize: 25, sortBy: 'bookCount', sortOrder: 'asc', bookCount: 'empty' }),
|
||||
).resolves.toEqual({
|
||||
items: [{ id: 99, name: 'Unused', bookCount: 0 }],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
expect(relationWhere).toHaveBeenCalledTimes(2);
|
||||
expect(dynamic.having).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges source series memberships into a target for scoped books only', async () => {
|
||||
@@ -260,9 +313,9 @@ describe('SeriesStrategy', () => {
|
||||
expect(statements.some((statement) => statement.includes('UPDATE book_metadata bm'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not start a delete transaction when no scoped books are affected', async () => {
|
||||
const transaction = vi.fn();
|
||||
const strategy = makeStrategy({ transaction });
|
||||
it('hard deletes an unused series row when no scoped books are affected', async () => {
|
||||
const execute = vi.fn().mockResolvedValue(undefined);
|
||||
const strategy = makeStrategy({ execute });
|
||||
vi.spyOn(strategy, 'findEntityById').mockResolvedValue({ id: 2, name: 'Old' });
|
||||
vi.spyOn(strategy as never, 'findAffectedBookIdsInLibraries').mockResolvedValue([]);
|
||||
|
||||
@@ -271,7 +324,7 @@ describe('SeriesStrategy', () => {
|
||||
affectedBookIds: [],
|
||||
});
|
||||
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when deleting a missing series', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SQL, and, asc, count, desc, eq, ilike, inArray, sql } from 'drizzle-orm';
|
||||
import { SQL, and, asc, count, desc, eq, exists, ilike, inArray, notExists, or, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { buildContentFilterClauses } from '../../../common/utils/content-filter-sql.utils';
|
||||
@@ -113,43 +113,64 @@ export class SeriesStrategy implements EntityStrategy {
|
||||
}
|
||||
|
||||
async browse(params: BrowseParams): Promise<BrowseResult> {
|
||||
if (params.libraryIds.length === 0) return { items: [], total: 0 };
|
||||
|
||||
const filterClauses = params.contentFilters ? buildContentFilterClauses(params.contentFilters, this.db) : [];
|
||||
const conditions = [inArray(books.libraryId, params.libraryIds), ...filterClauses];
|
||||
if (params.search) {
|
||||
conditions.push(ilike(bookSeries.name, `%${escapeLike(params.search)}%`) as never);
|
||||
}
|
||||
|
||||
const where = and(...conditions)!;
|
||||
const bookSubquery =
|
||||
params.libraryIds.length > 0
|
||||
? this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.where(and(inArray(books.libraryId, params.libraryIds), ...filterClauses))
|
||||
: null;
|
||||
const nameCondition = params.search ? (ilike(bookSeries.name, `%${escapeLike(params.search)}%`) as never) : undefined;
|
||||
const joinCondition = bookSubquery
|
||||
? and(eq(bookSeriesMemberships.seriesId, bookSeries.id), inArray(bookSeriesMemberships.bookId, bookSubquery))
|
||||
: and(eq(bookSeriesMemberships.seriesId, bookSeries.id), sql`false`);
|
||||
const bookCountExpr = sql<number>`count(distinct ${bookSeriesMemberships.bookId})::int`;
|
||||
|
||||
const [countResult, itemRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ total: sql<number>`count(distinct ${bookSeries.id})::int` })
|
||||
.from(bookSeries)
|
||||
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.seriesId, bookSeries.id))
|
||||
.innerJoin(books, eq(books.id, bookSeriesMemberships.bookId))
|
||||
.where(where),
|
||||
this.db
|
||||
.select({ id: bookSeries.id, name: bookSeries.name, bookCount: bookCountExpr })
|
||||
.from(bookSeries)
|
||||
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.seriesId, bookSeries.id))
|
||||
.innerJoin(books, eq(books.id, bookSeriesMemberships.bookId))
|
||||
.where(where)
|
||||
.groupBy(bookSeries.id, bookSeries.name)
|
||||
.orderBy(
|
||||
params.sortBy === 'bookCount'
|
||||
? params.sortOrder === 'asc'
|
||||
? asc(bookCountExpr)
|
||||
: desc(bookCountExpr)
|
||||
: params.sortOrder === 'asc'
|
||||
? asc(bookSeries.name)
|
||||
: desc(bookSeries.name),
|
||||
const hasScopedBooks = bookSubquery
|
||||
? exists(
|
||||
this.db
|
||||
.select({ one: sql`1` })
|
||||
.from(bookSeriesMemberships)
|
||||
.where(and(eq(bookSeriesMemberships.seriesId, bookSeries.id), inArray(bookSeriesMemberships.bookId, bookSubquery))),
|
||||
)
|
||||
.limit(params.pageSize)
|
||||
.offset((params.page - 1) * params.pageSize),
|
||||
]);
|
||||
: undefined;
|
||||
const isGloballyEmpty = notExists(
|
||||
this.db
|
||||
.select({ one: sql`1` })
|
||||
.from(bookSeriesMemberships)
|
||||
.where(eq(bookSeriesMemberships.seriesId, bookSeries.id)),
|
||||
);
|
||||
const visibilityCondition =
|
||||
params.bookCount === 'empty' ? isGloballyEmpty : hasScopedBooks ? or(hasScopedBooks, isGloballyEmpty) : isGloballyEmpty;
|
||||
|
||||
const countQuery = this.db
|
||||
.select({ total: sql<number>`count(distinct ${bookSeries.id})::int` })
|
||||
.from(bookSeries)
|
||||
.where(and(nameCondition, visibilityCondition));
|
||||
|
||||
const itemQuery = this.db
|
||||
.select({ id: bookSeries.id, name: bookSeries.name, bookCount: bookCountExpr })
|
||||
.from(bookSeries)
|
||||
.leftJoin(bookSeriesMemberships, joinCondition)
|
||||
.where(and(nameCondition, visibilityCondition))
|
||||
.groupBy(bookSeries.id, bookSeries.name)
|
||||
.$dynamic();
|
||||
|
||||
itemQuery
|
||||
.orderBy(
|
||||
params.sortBy === 'bookCount'
|
||||
? params.sortOrder === 'asc'
|
||||
? asc(bookCountExpr)
|
||||
: desc(bookCountExpr)
|
||||
: params.sortOrder === 'asc'
|
||||
? asc(bookSeries.name)
|
||||
: desc(bookSeries.name),
|
||||
)
|
||||
.limit(params.pageSize)
|
||||
.offset((params.page - 1) * params.pageSize);
|
||||
|
||||
const [countResult, itemRows] = await Promise.all([countQuery, itemQuery]);
|
||||
|
||||
return {
|
||||
items: itemRows.map((row) => ({ id: row.id, name: row.name, bookCount: row.bookCount })),
|
||||
@@ -227,6 +248,8 @@ export class SeriesStrategy implements EntityStrategy {
|
||||
await this.syncPrimaryMetadataForBooks(affectedBookIds, tx as unknown as Db);
|
||||
await this.deleteUnusedSeriesRows([entityId], tx as unknown as Db);
|
||||
});
|
||||
} else if (input.mode === 'hard') {
|
||||
await this.deleteUnusedSeriesRows([entityId], this.db);
|
||||
}
|
||||
|
||||
return { name: entity.name, affectedBookIds };
|
||||
|
||||
Reference in New Issue
Block a user