mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(books): update metadata and locks atomically
When a newly filled metadata field is locked before saving, the subsequent save causes a 409 conflict because the field was locked but not yet saved. This introduces a new endpoint and DTO to update both metadata and locks simultaneously, resolving the conflict. Closes #172
This commit is contained in:
@@ -80,13 +80,16 @@ const {
|
||||
updating: updatingLocks,
|
||||
error: lockError,
|
||||
areAllLocked,
|
||||
locksDirty,
|
||||
load: loadLocks,
|
||||
reset: resetLocks,
|
||||
markPersisted: markLocksPersisted,
|
||||
isLocked,
|
||||
isUpdating: isUpdatingLock,
|
||||
toggle,
|
||||
lockAll,
|
||||
unlockAll,
|
||||
} = useMetadataLocks()
|
||||
} = useMetadataLocks({ deferred: true })
|
||||
const { search: searchAuthors } = useAuthorSearch()
|
||||
const { search: searchNarrators } = useNarratorSearch()
|
||||
const { search: searchGenres } = useGenreSearch()
|
||||
@@ -142,14 +145,25 @@ watch(
|
||||
|
||||
const combinedError = computed(() => lockError.value ?? error.value)
|
||||
const hasLockedFields = computed(() => lockedFields.value.length > 0)
|
||||
const hasPendingChanges = computed(() => isDirty.value || locksDirty.value)
|
||||
|
||||
async function submit() {
|
||||
if (coverPanel.value?.hasPending) {
|
||||
const ok = await coverPanel.value.confirm()
|
||||
if (ok) emit('coverChanged', 'custom')
|
||||
}
|
||||
const updated = await save(props.book.id)
|
||||
if (updated) emit('saved', updated)
|
||||
const shouldSaveLocks = locksDirty.value
|
||||
const updated = await save(props.book.id, { saveLocks: shouldSaveLocks, lockedFields: lockedFields.value })
|
||||
if (updated) {
|
||||
markLocksPersisted(updated.lockedFields)
|
||||
emit('saved', updated)
|
||||
if (shouldSaveLocks) emit('locksChanged', updated.lockedFields)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
reset()
|
||||
resetLocks()
|
||||
}
|
||||
|
||||
const hoverRating = ref<number | null>(null)
|
||||
@@ -391,8 +405,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
async function handleLockToggle(field: BookMetadataLockField) {
|
||||
const updated = await toggle(props.book.id, field)
|
||||
if (updated) emit('locksChanged', updated.lockedFields)
|
||||
await toggle(props.book.id, field)
|
||||
}
|
||||
|
||||
function handleCoverLockToggle() {
|
||||
@@ -400,13 +413,11 @@ function handleCoverLockToggle() {
|
||||
}
|
||||
|
||||
async function handleLockAll() {
|
||||
const updated = await lockAll(props.book.id)
|
||||
if (updated) emit('locksChanged', updated.lockedFields)
|
||||
await lockAll(props.book.id)
|
||||
}
|
||||
|
||||
async function handleUnlockAll() {
|
||||
const updated = await unlockAll(props.book.id)
|
||||
if (updated) emit('locksChanged', updated.lockedFields)
|
||||
await unlockAll(props.book.id)
|
||||
}
|
||||
|
||||
function handleCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
@@ -530,15 +541,15 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
|
||||
:disabled="!isDirty || saving"
|
||||
@click="reset"
|
||||
:disabled="!hasPendingChanges || saving"
|
||||
@click="handleReset"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-8 px-2.5 sm:px-3 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
|
||||
:disabled="!isDirty || saving"
|
||||
:disabled="!hasPendingChanges || saving"
|
||||
@click="submit"
|
||||
>
|
||||
<Loader2 v-if="saving" class="size-3.5 animate-spin" />
|
||||
@@ -1073,15 +1084,15 @@ function handleCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1.5 h-9 px-4 rounded-lg border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-40"
|
||||
:disabled="!isDirty || saving"
|
||||
@click="reset"
|
||||
:disabled="!hasPendingChanges || saving"
|
||||
@click="handleReset"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1.5 h-9 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-40"
|
||||
:disabled="!isDirty || saving"
|
||||
:disabled="!hasPendingChanges || saving"
|
||||
@click="submit"
|
||||
>
|
||||
<Loader2 v-if="saving" class="size-3.5 animate-spin" />
|
||||
|
||||
@@ -127,4 +127,37 @@ describe('useMetadataEditor', () => {
|
||||
publisher: 'Updated Publisher',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves changed metadata and final locks through the atomic endpoint', async () => {
|
||||
const book = makeBook({ providerIds: { goodreads: null } })
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, lockedFields: ['goodreadsId'] }) })
|
||||
|
||||
const { form, load, save } = useMetadataEditor()
|
||||
load(book)
|
||||
form.goodreadsId = 'manual-goodreads-id'
|
||||
await save(book.id, { saveLocks: true, lockedFields: ['goodreadsId'] })
|
||||
|
||||
const [url, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/v1/books/1/metadata-and-locks')
|
||||
expect(JSON.parse(String(req.body))).toEqual({
|
||||
metadata: { goodreadsId: 'manual-goodreads-id' },
|
||||
lockedFields: ['goodreadsId'],
|
||||
})
|
||||
})
|
||||
|
||||
it('can save lock-only changes through the atomic endpoint without metadata fields', async () => {
|
||||
const book = makeBook()
|
||||
apiMock.mockResolvedValue({ ok: true, json: async () => ({ ...book, lockedFields: ['title'] }) })
|
||||
|
||||
const { load, save } = useMetadataEditor()
|
||||
load(book)
|
||||
await save(book.id, { saveLocks: true, lockedFields: ['title'] })
|
||||
|
||||
const [url, req] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/v1/books/1/metadata-and-locks')
|
||||
expect(JSON.parse(String(req.body))).toEqual({
|
||||
metadata: {},
|
||||
lockedFields: ['title'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,4 +120,37 @@ describe('useMetadataLocks', () => {
|
||||
expect(result).toBeNull()
|
||||
expect(error.value).toBe('HTTP 409')
|
||||
})
|
||||
|
||||
it('stages lock changes without calling the API in deferred mode', async () => {
|
||||
const { load, toggle, isLocked, locksDirty } = useMetadataLocks({ deferred: true })
|
||||
load(makeBook())
|
||||
|
||||
await toggle(1, 'goodreadsId')
|
||||
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
expect(isLocked('goodreadsId')).toBe(true)
|
||||
expect(locksDirty.value).toBe(true)
|
||||
})
|
||||
|
||||
it('resets deferred lock changes to the persisted lock state', async () => {
|
||||
const { load, toggle, reset, lockedFields, locksDirty } = useMetadataLocks({ deferred: true })
|
||||
load(makeBook({ lockedFields: ['title'] }))
|
||||
|
||||
await toggle(1, 'goodreadsId')
|
||||
reset()
|
||||
|
||||
expect(lockedFields.value).toEqual(['title'])
|
||||
expect(locksDirty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('marks deferred lock changes as persisted after a successful combined save', async () => {
|
||||
const { load, toggle, markPersisted, lockedFields, locksDirty } = useMetadataLocks({ deferred: true })
|
||||
load(makeBook())
|
||||
|
||||
await toggle(1, 'title')
|
||||
markPersisted(['title'])
|
||||
|
||||
expect(lockedFields.value).toEqual(['title'])
|
||||
expect(locksDirty.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { FORMAT_TO_GROUP, type BookDetail } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, type BookDetail, type BookMetadataLockField } from '@bookorbit/types'
|
||||
|
||||
const ROOT_FIELDS = [
|
||||
'title',
|
||||
@@ -181,14 +181,18 @@ export function useMetadataEditor() {
|
||||
return payload
|
||||
}
|
||||
|
||||
async function save(bookId: number): Promise<BookDetail | null> {
|
||||
async function save(
|
||||
bookId: number,
|
||||
options: { lockedFields?: readonly BookMetadataLockField[]; saveLocks?: boolean } = {},
|
||||
): Promise<BookDetail | null> {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await api(`/api/v1/books/${bookId}/metadata`, {
|
||||
const metadata = buildPayload()
|
||||
const res = await api(options.saveLocks ? `/api/v1/books/${bookId}/metadata-and-locks` : `/api/v1/books/${bookId}/metadata`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildPayload()),
|
||||
body: JSON.stringify(options.saveLocks ? { metadata, lockedFields: options.lockedFields ?? [] } : metadata),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated: BookDetail = await res.json()
|
||||
|
||||
@@ -2,22 +2,46 @@ import { computed, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { BOOK_METADATA_LOCK_FIELDS, type BookDetail, type BookMetadataLockField } from '@bookorbit/types'
|
||||
|
||||
interface UseMetadataLocksOptions {
|
||||
deferred?: boolean
|
||||
}
|
||||
|
||||
function normalizeLockedFields(fields: readonly BookMetadataLockField[]): BookMetadataLockField[] {
|
||||
const unique = new Set(fields)
|
||||
return BOOK_METADATA_LOCK_FIELDS.filter((field) => unique.has(field))
|
||||
}
|
||||
|
||||
export function useMetadataLocks() {
|
||||
function lockKey(fields: readonly BookMetadataLockField[]): string {
|
||||
return JSON.stringify(normalizeLockedFields(fields))
|
||||
}
|
||||
|
||||
export function useMetadataLocks(options: UseMetadataLocksOptions = {}) {
|
||||
const lockedFields = ref<BookMetadataLockField[]>([])
|
||||
const persistedLockedFields = ref<BookMetadataLockField[]>([])
|
||||
const updatingField = ref<BookMetadataLockField | 'all' | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const updating = computed(() => updatingField.value !== null)
|
||||
const lockedFieldSet = computed(() => new Set(lockedFields.value))
|
||||
const areAllLocked = computed(() => BOOK_METADATA_LOCK_FIELDS.every((field) => lockedFieldSet.value.has(field)))
|
||||
const locksDirty = computed(() => lockKey(lockedFields.value) !== lockKey(persistedLockedFields.value))
|
||||
|
||||
function load(book: BookDetail) {
|
||||
lockedFields.value = normalizeLockedFields(book.lockedFields)
|
||||
const normalized = normalizeLockedFields(book.lockedFields)
|
||||
lockedFields.value = [...normalized]
|
||||
persistedLockedFields.value = [...normalized]
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lockedFields.value = [...persistedLockedFields.value]
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function markPersisted(fields: readonly BookMetadataLockField[]) {
|
||||
const normalized = normalizeLockedFields(fields)
|
||||
lockedFields.value = [...normalized]
|
||||
persistedLockedFields.value = [...normalized]
|
||||
error.value = null
|
||||
}
|
||||
|
||||
@@ -34,6 +58,10 @@ export function useMetadataLocks() {
|
||||
error.value = null
|
||||
try {
|
||||
const normalized = normalizeLockedFields(nextLockedFields)
|
||||
if (options.deferred) {
|
||||
lockedFields.value = normalized
|
||||
return null
|
||||
}
|
||||
const res = await api(`/api/v1/books/${bookId}/metadata-locks`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -41,7 +69,7 @@ export function useMetadataLocks() {
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated: BookDetail = await res.json()
|
||||
lockedFields.value = normalizeLockedFields(updated.lockedFields)
|
||||
markPersisted(updated.lockedFields)
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to update locks'
|
||||
@@ -73,7 +101,10 @@ export function useMetadataLocks() {
|
||||
updating,
|
||||
error,
|
||||
areAllLocked,
|
||||
locksDirty,
|
||||
load,
|
||||
reset,
|
||||
markPersisted,
|
||||
isLocked,
|
||||
isUpdating,
|
||||
replace,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { bookMetadata } from '../../db/schema';
|
||||
import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type LockMutationExecutor = Pick<Db, 'insert'>;
|
||||
|
||||
@Injectable()
|
||||
export class BookMetadataLockRepository {
|
||||
@@ -31,9 +32,9 @@ export class BookMetadataLockRepository {
|
||||
return new Map(rows.map((r) => [r.bookId, r.lockedFields ?? []]));
|
||||
}
|
||||
|
||||
async replaceLockedFields(bookId: number, lockedFields: BookMetadataLockField[]): Promise<void> {
|
||||
async replaceLockedFields(bookId: number, lockedFields: BookMetadataLockField[], executor: LockMutationExecutor = this.db): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
await executor
|
||||
.insert(bookMetadata)
|
||||
.values({ bookId, lockedFields, updatedAt: now })
|
||||
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { lockedFields, updatedAt: now } });
|
||||
|
||||
@@ -38,6 +38,35 @@ describe('BookMetadataLockService', () => {
|
||||
await expect(service.assertManualUpdateAllowed(12, dto)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows metadata changes when the same request newly locks the targeted field', async () => {
|
||||
const { service } = makeService([]);
|
||||
|
||||
await expect(service.assertManualUpdateAllowedForLockTransition(12, { goodreadsId: 'manual-id' }, ['goodreadsId'])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows metadata changes when the same request unlocks the targeted field', async () => {
|
||||
const { service } = makeService(['publisher']);
|
||||
|
||||
await expect(service.assertManualUpdateAllowedForLockTransition(12, { publisher: 'Unlocked Publisher' }, [])).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects metadata changes for fields that stay locked across the request', async () => {
|
||||
const { service } = makeService(['title', 'publisher']);
|
||||
|
||||
await expect(service.assertManualUpdateAllowedForLockTransition(12, { title: 'Still Locked' }, ['title'])).rejects.toThrow(
|
||||
'Metadata fields are locked: title',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes transaction executors through lock replacement', async () => {
|
||||
const { service, lockRepo } = makeService();
|
||||
const tx = { id: 'tx' };
|
||||
|
||||
await service.replaceLockedFields(12, ['cover'], tx as never);
|
||||
|
||||
expect(lockRepo.replaceLockedFields).toHaveBeenCalledWith(12, ['cover'], tx);
|
||||
});
|
||||
|
||||
it('filters automated dto updates and preserves unlocked chapters', async () => {
|
||||
const { service } = makeService(['title', 'narrators', 'comicIssueNumber', 'googleBooksId']);
|
||||
|
||||
|
||||
@@ -64,9 +64,13 @@ export class BookMetadataLockService {
|
||||
return fields.includes(field);
|
||||
}
|
||||
|
||||
async replaceLockedFields(bookId: number, lockedFields: readonly string[]): Promise<BookMetadataLockField[]> {
|
||||
async replaceLockedFields(
|
||||
bookId: number,
|
||||
lockedFields: readonly string[],
|
||||
executor?: Parameters<BookMetadataLockRepository['replaceLockedFields']>[2],
|
||||
): Promise<BookMetadataLockField[]> {
|
||||
const normalized = this.normalizeLockedFields(lockedFields);
|
||||
await this.lockRepo.replaceLockedFields(bookId, normalized);
|
||||
await this.lockRepo.replaceLockedFields(bookId, normalized, executor);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -109,6 +113,17 @@ export class BookMetadataLockService {
|
||||
await this.assertFieldsUnlocked(bookId, this.getFieldsTargetedByBookUpdate(dto));
|
||||
}
|
||||
|
||||
async assertManualUpdateAllowedForLockTransition(bookId: number, dto: UpdateBookMetadataDto, nextLockedFields: readonly string[]): Promise<void> {
|
||||
const currentlyLocked = new Set(await this.getLockedFields(bookId));
|
||||
const nextLocked = new Set(this.normalizeLockedFields(nextLockedFields));
|
||||
const lockedBeforeAndAfter = [...currentlyLocked].filter((field) => nextLocked.has(field));
|
||||
const targetedFields = this.getFieldsTargetedByBookUpdate(dto);
|
||||
const blockedFields = targetedFields.filter((field) => lockedBeforeAndAfter.includes(field));
|
||||
if (blockedFields.length === 0) return;
|
||||
|
||||
throw new ConflictException(`Metadata fields are locked: ${blockedFields.join(', ')}`);
|
||||
}
|
||||
|
||||
async filterAutomatedBookUpdate(
|
||||
bookId: number,
|
||||
dto: UpdateBookMetadataDto,
|
||||
|
||||
@@ -140,6 +140,7 @@ function makeController() {
|
||||
clearFileProgress: vi.fn(),
|
||||
saveAudioProgress: vi.fn(),
|
||||
updateMetadata: vi.fn(),
|
||||
updateMetadataAndLocks: vi.fn(),
|
||||
updateMetadataLocks: vi.fn(),
|
||||
refreshMetadata: vi.fn(),
|
||||
getMetadataFromFile: vi.fn(),
|
||||
@@ -761,6 +762,7 @@ describe('BookController', () => {
|
||||
const { controller, bookService } = makeController();
|
||||
const user = makeUser();
|
||||
bookService.updateMetadata.mockResolvedValue({ id: 7 });
|
||||
bookService.updateMetadataAndLocks.mockResolvedValue({ id: 7, lockedFields: ['goodreadsId'] });
|
||||
bookService.updateMetadataLocks.mockResolvedValue({ id: 7, lockedFields: ['title'] });
|
||||
bookService.refreshMetadata.mockResolvedValue({ id: 7 });
|
||||
bookService.getMetadataFromFile.mockResolvedValue({ title: 'File Title' });
|
||||
@@ -769,6 +771,7 @@ describe('BookController', () => {
|
||||
bookService.getDetail.mockResolvedValue({ id: 7, title: 'Detail' });
|
||||
|
||||
await controller.updateMetadata(7, { title: 'New' } as never, user);
|
||||
await controller.updateMetadataAndLocks(7, { metadata: { goodreadsId: '123' }, lockedFields: ['goodreadsId'] } as never, user);
|
||||
await controller.bulkSetMetadata({ bookIds: [7, 8], field: 'language', value: 'fr' } as never, user);
|
||||
await controller.updateMetadataLocks(7, { lockedFields: ['title'] } as never, user);
|
||||
await controller.refreshMetadata(7, 'true', user);
|
||||
@@ -779,6 +782,7 @@ describe('BookController', () => {
|
||||
await controller.getDetail(7, user);
|
||||
|
||||
expect(bookService.updateMetadata).toHaveBeenCalledWith(7, { title: 'New' }, user);
|
||||
expect(bookService.updateMetadataAndLocks).toHaveBeenCalledWith(7, { metadata: { goodreadsId: '123' }, lockedFields: ['goodreadsId'] }, user);
|
||||
expect(bookService.bulkSetMetadata).toHaveBeenCalledWith([7, 8], 'language', 'fr', user);
|
||||
expect(bookService.updateMetadataLocks).toHaveBeenCalledWith(7, ['title'], user);
|
||||
expect(bookService.refreshMetadata).toHaveBeenCalledWith(7, true, user);
|
||||
@@ -816,6 +820,10 @@ describe('BookController', () => {
|
||||
getResourceId: (req: { params: Record<string, string> }) => number;
|
||||
description: (req: { params: Record<string, string> }) => string;
|
||||
};
|
||||
const updateMetadataAndLocksAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.updateMetadataAndLocks) as {
|
||||
getResourceId: (req: { params: Record<string, string> }) => number;
|
||||
description: (req: { params: Record<string, string> }) => string;
|
||||
};
|
||||
const updateLocksAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.updateMetadataLocks) as {
|
||||
getResourceId: (req: { params: Record<string, string> }) => number;
|
||||
description: (req: { params: Record<string, string> }) => string;
|
||||
@@ -831,6 +839,8 @@ describe('BookController', () => {
|
||||
expect(bulkSetMetadataAudit.description({ body: { bookIds: [1, 2], field: 'language' } })).toBe('Bulk set language for 2 books');
|
||||
expect(updateMetadataAudit.getResourceId({ params: { id: '44' } })).toBe(44);
|
||||
expect(updateMetadataAudit.description({ params: { id: '44' } })).toBe('Updated metadata for book #44');
|
||||
expect(updateMetadataAndLocksAudit.getResourceId({ params: { id: '44' } })).toBe(44);
|
||||
expect(updateMetadataAndLocksAudit.description({ params: { id: '44' } })).toBe('Updated metadata and locks for book #44');
|
||||
expect(updateLocksAudit.getResourceId({ params: { id: '44' } })).toBe(44);
|
||||
expect(updateLocksAudit.description({ params: { id: '44' } })).toBe('Updated metadata locks for book #44');
|
||||
expect(refreshAudit.getResourceId({ params: { id: '44' } })).toBe(44);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ExportBooksDto } from './dto/export-books.dto';
|
||||
import { MetadataExportDto } from './dto/metadata-export.dto';
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
|
||||
import { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
|
||||
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
import { SearchBooksDto } from './dto/search-books.dto';
|
||||
import { SetStatusDto } from '../user-book-status/dto/set-status.dto';
|
||||
@@ -508,6 +509,18 @@ export class BookController {
|
||||
return this.bookService.updateMetadata(id, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':id/metadata-and-locks')
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
@Auditable({
|
||||
action: AuditAction.BookMetadataUpdate,
|
||||
resource: AuditResource.Book,
|
||||
getResourceId: (req) => parseInt(req.params['id'] as string, 10),
|
||||
description: (req) => `Updated metadata and locks for book #${req.params['id']}`,
|
||||
})
|
||||
updateMetadataAndLocks(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBookMetadataAndLocksDto, @CurrentUser() user: RequestUser) {
|
||||
return this.bookService.updateMetadataAndLocks(id, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':id/metadata-locks')
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
@Auditable({
|
||||
|
||||
@@ -175,6 +175,7 @@ function makeService() {
|
||||
normalizeLockedFields: vi.fn().mockImplementation((fields: string[] | null | undefined) => fields ?? []),
|
||||
isFieldLocked: vi.fn().mockResolvedValue(false),
|
||||
assertManualUpdateAllowed: vi.fn().mockResolvedValue(undefined),
|
||||
assertManualUpdateAllowedForLockTransition: vi.fn().mockResolvedValue(undefined),
|
||||
filterResolvedMetadata: vi.fn().mockImplementation((_bookId: number, resolved: unknown, providerIds: unknown) =>
|
||||
Promise.resolve({
|
||||
resolved,
|
||||
@@ -1159,6 +1160,78 @@ describe('BookService', () => {
|
||||
expect(bookRepo.withTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateMetadataAndLocks persists edited metadata and final locks in one transaction', async () => {
|
||||
const { service, bookRepo, bookMetadataLockService } = makeService();
|
||||
const user = makeUser();
|
||||
const tx = { id: 'tx-locks' };
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['goodreadsId'] } as never);
|
||||
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
|
||||
bookMetadataLockService.replaceLockedFields.mockResolvedValue(['goodreadsId']);
|
||||
|
||||
const result = await service.updateMetadataAndLocks(
|
||||
5,
|
||||
{
|
||||
metadata: { goodreadsId: 'manual-goodreads-id' },
|
||||
lockedFields: ['goodreadsId'],
|
||||
},
|
||||
user,
|
||||
);
|
||||
|
||||
expect(bookMetadataLockService.assertManualUpdateAllowedForLockTransition).toHaveBeenCalledWith(5, { goodreadsId: 'manual-goodreads-id' }, [
|
||||
'goodreadsId',
|
||||
]);
|
||||
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({
|
||||
goodreadsId: 'manual-goodreads-id',
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(bookMetadataLockService.replaceLockedFields).toHaveBeenCalledWith(5, ['goodreadsId'], tx);
|
||||
expect(result).toEqual({ id: 5, lockedFields: ['goodreadsId'] });
|
||||
});
|
||||
|
||||
it('updateMetadataAndLocks rejects fields locked before and after without opening a transaction', async () => {
|
||||
const { service, bookRepo, bookMetadataLockService } = makeService();
|
||||
const user = makeUser();
|
||||
const error = new ConflictException('Metadata fields are locked: title');
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
bookMetadataLockService.assertManualUpdateAllowedForLockTransition.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.updateMetadataAndLocks(
|
||||
5,
|
||||
{
|
||||
metadata: { title: 'Blocked Title' },
|
||||
lockedFields: ['title'],
|
||||
},
|
||||
user,
|
||||
),
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(bookRepo.withTransaction).not.toHaveBeenCalled();
|
||||
expect(bookMetadataLockService.replaceLockedFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateMetadataAndLocks can persist locks without scheduling metadata side effects', async () => {
|
||||
const { service, bookRepo, bookMetadataLockService, embedder, fileWriteService, fileRenameService, scoreService } = makeService();
|
||||
const user = makeUser();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['title'] } as never);
|
||||
bookMetadataLockService.replaceLockedFields.mockResolvedValue(['title']);
|
||||
|
||||
await service.updateMetadataAndLocks(5, { metadata: {}, lockedFields: ['title'] }, user);
|
||||
|
||||
expect(bookRepo.updateMetadataFields).not.toHaveBeenCalled();
|
||||
expect(bookMetadataLockService.replaceLockedFields).toHaveBeenCalledWith(5, ['title'], expect.anything());
|
||||
expect(embedder.embedBook).not.toHaveBeenCalled();
|
||||
expect(fileWriteService.scheduleWrite).not.toHaveBeenCalled();
|
||||
expect(fileRenameService.scheduleRename).not.toHaveBeenCalled();
|
||||
expect(scoreService.calculateAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateMetadata does not clear omitted scalar fields on transformed dto instances', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
@@ -74,6 +74,7 @@ import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto
|
||||
import { SaveProgressDto } from './dto/save-progress.dto';
|
||||
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
|
||||
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
import type { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
|
||||
import { buildBookDetailSupplementalFields } from './utils/build-book-detail-supplemental-fields';
|
||||
import type { SetStatusDto } from '../user-book-status/dto/set-status.dto';
|
||||
|
||||
@@ -1108,94 +1109,7 @@ export class BookService {
|
||||
try {
|
||||
await this.verifyBookAccess(id, user);
|
||||
await this.bookMetadataLockService.assertManualUpdateAllowed(id, dto);
|
||||
|
||||
const scalarFields: Parameters<BookRepository['updateMetadataFields']>[1] = {};
|
||||
if (dto.title !== undefined) scalarFields.title = dto.title ?? null;
|
||||
if (dto.subtitle !== undefined) scalarFields.subtitle = dto.subtitle ?? null;
|
||||
if (dto.description !== undefined) scalarFields.description = dto.description ?? null;
|
||||
if (dto.publisher !== undefined) scalarFields.publisher = dto.publisher ?? null;
|
||||
if (dto.publishedYear !== undefined) scalarFields.publishedYear = dto.publishedYear ?? null;
|
||||
if (dto.language !== undefined) scalarFields.language = dto.language ?? null;
|
||||
if (dto.pageCount !== undefined) scalarFields.pageCount = dto.pageCount ?? null;
|
||||
if (dto.seriesName !== undefined) scalarFields.seriesName = dto.seriesName ?? null;
|
||||
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
|
||||
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
|
||||
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null;
|
||||
if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
|
||||
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
|
||||
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
|
||||
if (dto.hardcoverId !== undefined) scalarFields.hardcoverId = dto.hardcoverId ?? null;
|
||||
if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null;
|
||||
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
|
||||
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
|
||||
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
|
||||
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
|
||||
if (dto.audioMetadata) {
|
||||
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
|
||||
if (dto.audioMetadata.abridged !== undefined) scalarFields.abridged = dto.audioMetadata.abridged ?? false;
|
||||
if (dto.audioMetadata.chapters !== undefined) scalarFields.chapters = dto.audioMetadata.chapters ?? null;
|
||||
}
|
||||
|
||||
const scalarFieldCount = Object.keys(scalarFields).length;
|
||||
let replacedAuthorIds: number[] = [];
|
||||
await this.bookRepo.withTransaction(async (tx) => {
|
||||
if (scalarFieldCount > 0) {
|
||||
scalarFields.updatedAt = new Date();
|
||||
await this.bookRepo.updateMetadataFields(id, scalarFields, tx);
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterScalarUpdate');
|
||||
|
||||
if (dto.comicMetadata) {
|
||||
await this.comicMetadataService.upsert(id, dto.comicMetadata, tx);
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterComicMetadataUpsert');
|
||||
|
||||
if (dto.authors !== undefined) {
|
||||
replacedAuthorIds = await this.metadataService.replaceAuthors(
|
||||
id,
|
||||
dto.authors.map((name) => ({ name, sortName: null })),
|
||||
{ executor: tx, emitEvent: false },
|
||||
);
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterAuthorsReplace');
|
||||
|
||||
if (dto.audioMetadata?.narrators !== undefined) {
|
||||
await this.narratorService.replaceForBook(id, dto.audioMetadata.narrators, { executor: tx });
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterNarratorsReplace');
|
||||
|
||||
if (dto.genres !== undefined) {
|
||||
await this.metadataService.replaceGenres(id, dto.genres, { executor: tx });
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterGenresReplace');
|
||||
|
||||
if (dto.tags !== undefined) {
|
||||
await this.metadataService.replaceTags(id, dto.tags, { executor: tx });
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterTagsReplace');
|
||||
this.throwIfMetadataUpdateFailpoint('beforeTransactionCommit');
|
||||
});
|
||||
|
||||
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
|
||||
|
||||
if (dto.rating !== undefined) {
|
||||
const rating = dto.rating ?? null;
|
||||
await this.bookRepo.bulkSetRating([id], rating, user.id);
|
||||
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
|
||||
userId: user.id,
|
||||
bookIds: [id],
|
||||
rating,
|
||||
});
|
||||
}
|
||||
|
||||
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
|
||||
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
|
||||
const hasRenameRelevantField = Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
|
||||
if (hasRenameRelevantField) {
|
||||
this.fileRenameService?.scheduleRename(id, user.id);
|
||||
}
|
||||
this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`));
|
||||
const detail = await this.getDetail(id, user);
|
||||
const { detail, scalarFieldCount } = await this.persistMetadataUpdate(id, dto, user);
|
||||
this.logger.log(
|
||||
`[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} authorsUpdated=${dto.authors !== undefined} narratorsUpdated=${dto.audioMetadata?.narrators !== undefined} genresUpdated=${dto.genres !== undefined} tagsUpdated=${dto.tags !== undefined} audioMetadataUpdated=${dto.audioMetadata !== undefined} comicMetadataUpdated=${dto.comicMetadata !== undefined} - metadata update completed`,
|
||||
);
|
||||
@@ -1210,6 +1124,142 @@ export class BookService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateMetadataAndLocks(id: number, dto: UpdateBookMetadataAndLocksDto, user: RequestUser): Promise<BookDetailDto> {
|
||||
const event = 'book.update_metadata_and_locks';
|
||||
const startedAt = Date.now();
|
||||
const metadata = dto.metadata ?? {};
|
||||
this.logger.log(
|
||||
`[${event}] [start] bookId=${id} userId=${user.id} metadataFields=${Object.keys(metadata).length} lockFields=${dto.lockedFields.length} - metadata and lock update started`,
|
||||
);
|
||||
try {
|
||||
await this.verifyBookAccess(id, user);
|
||||
await this.bookMetadataLockService.assertManualUpdateAllowedForLockTransition(id, metadata, dto.lockedFields);
|
||||
const { detail, scalarFieldCount, normalizedLockedFields } = await this.persistMetadataUpdate(id, metadata, user, {
|
||||
lockedFields: dto.lockedFields,
|
||||
});
|
||||
this.logger.log(
|
||||
`[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} lockFields=${normalizedLockedFields?.length ?? 0} - metadata and lock update completed`,
|
||||
);
|
||||
return detail;
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] bookId=${id} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - metadata and lock update failed`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistMetadataUpdate(
|
||||
id: number,
|
||||
dto: UpdateBookMetadataDto,
|
||||
user: RequestUser,
|
||||
options: { lockedFields?: readonly string[] } = {},
|
||||
): Promise<{ detail: BookDetailDto; scalarFieldCount: number; normalizedLockedFields?: BookMetadataLockField[] }> {
|
||||
const scalarFields: Parameters<BookRepository['updateMetadataFields']>[1] = {};
|
||||
if (dto.title !== undefined) scalarFields.title = dto.title ?? null;
|
||||
if (dto.subtitle !== undefined) scalarFields.subtitle = dto.subtitle ?? null;
|
||||
if (dto.description !== undefined) scalarFields.description = dto.description ?? null;
|
||||
if (dto.publisher !== undefined) scalarFields.publisher = dto.publisher ?? null;
|
||||
if (dto.publishedYear !== undefined) scalarFields.publishedYear = dto.publishedYear ?? null;
|
||||
if (dto.language !== undefined) scalarFields.language = dto.language ?? null;
|
||||
if (dto.pageCount !== undefined) scalarFields.pageCount = dto.pageCount ?? null;
|
||||
if (dto.seriesName !== undefined) scalarFields.seriesName = dto.seriesName ?? null;
|
||||
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
|
||||
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
|
||||
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null;
|
||||
if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
|
||||
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
|
||||
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
|
||||
if (dto.hardcoverId !== undefined) scalarFields.hardcoverId = dto.hardcoverId ?? null;
|
||||
if (dto.openLibraryId !== undefined) scalarFields.openLibraryId = dto.openLibraryId ?? null;
|
||||
if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null;
|
||||
if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null;
|
||||
if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null;
|
||||
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
|
||||
if (dto.audioMetadata) {
|
||||
if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null;
|
||||
if (dto.audioMetadata.abridged !== undefined) scalarFields.abridged = dto.audioMetadata.abridged ?? false;
|
||||
if (dto.audioMetadata.chapters !== undefined) scalarFields.chapters = dto.audioMetadata.chapters ?? null;
|
||||
}
|
||||
|
||||
const scalarFieldCount = Object.keys(scalarFields).length;
|
||||
const hasMetadataUpdate = Object.keys(dto).length > 0;
|
||||
let replacedAuthorIds: number[] = [];
|
||||
let normalizedLockedFields: BookMetadataLockField[] | undefined;
|
||||
|
||||
await this.bookRepo.withTransaction(async (tx) => {
|
||||
if (scalarFieldCount > 0) {
|
||||
scalarFields.updatedAt = new Date();
|
||||
await this.bookRepo.updateMetadataFields(id, scalarFields, tx);
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterScalarUpdate');
|
||||
|
||||
if (dto.comicMetadata) {
|
||||
await this.comicMetadataService.upsert(id, dto.comicMetadata, tx);
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterComicMetadataUpsert');
|
||||
|
||||
if (dto.authors !== undefined) {
|
||||
replacedAuthorIds = await this.metadataService.replaceAuthors(
|
||||
id,
|
||||
dto.authors.map((name) => ({ name, sortName: null })),
|
||||
{ executor: tx, emitEvent: false },
|
||||
);
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterAuthorsReplace');
|
||||
|
||||
if (dto.audioMetadata?.narrators !== undefined) {
|
||||
await this.narratorService.replaceForBook(id, dto.audioMetadata.narrators, { executor: tx });
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterNarratorsReplace');
|
||||
|
||||
if (dto.genres !== undefined) {
|
||||
await this.metadataService.replaceGenres(id, dto.genres, { executor: tx });
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterGenresReplace');
|
||||
|
||||
if (dto.tags !== undefined) {
|
||||
await this.metadataService.replaceTags(id, dto.tags, { executor: tx });
|
||||
}
|
||||
this.throwIfMetadataUpdateFailpoint('afterTagsReplace');
|
||||
|
||||
if (options.lockedFields !== undefined) {
|
||||
normalizedLockedFields = await this.bookMetadataLockService.replaceLockedFields(id, options.lockedFields, tx);
|
||||
}
|
||||
|
||||
this.throwIfMetadataUpdateFailpoint('beforeTransactionCommit');
|
||||
});
|
||||
|
||||
if (dto.authors !== undefined) {
|
||||
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
|
||||
}
|
||||
|
||||
if (dto.rating !== undefined) {
|
||||
const rating = dto.rating ?? null;
|
||||
await this.bookRepo.bulkSetRating([id], rating, user.id);
|
||||
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
|
||||
userId: user.id,
|
||||
bookIds: [id],
|
||||
rating,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMetadataUpdate) {
|
||||
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
|
||||
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
|
||||
const hasRenameRelevantField = Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
|
||||
if (hasRenameRelevantField) {
|
||||
this.fileRenameService?.scheduleRename(id, user.id);
|
||||
}
|
||||
this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`));
|
||||
}
|
||||
|
||||
const detail = await this.getDetail(id, user);
|
||||
return { detail, scalarFieldCount, normalizedLockedFields };
|
||||
}
|
||||
|
||||
async updateMetadataLocks(id: number, lockedFields: string[], user: RequestUser): Promise<BookDetailDto> {
|
||||
const event = 'book.update_metadata_locks';
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsIn, IsOptional, ValidateNested, ArrayUnique } from 'class-validator';
|
||||
import { BOOK_METADATA_LOCK_FIELDS, type BookMetadataLockField } from '@bookorbit/types';
|
||||
|
||||
import { UpdateBookMetadataDto } from './update-book-metadata.dto';
|
||||
|
||||
export class UpdateBookMetadataAndLocksDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateBookMetadataDto)
|
||||
metadata?: UpdateBookMetadataDto;
|
||||
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsIn(BOOK_METADATA_LOCK_FIELDS, { each: true })
|
||||
lockedFields!: BookMetadataLockField[];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { UpdateBookMetadataAndLocksDto } from './dto/update-book-metadata-and-locks.dto';
|
||||
|
||||
describe('UpdateBookMetadataAndLocksDto', () => {
|
||||
it('accepts metadata updates with a final lock set', async () => {
|
||||
const dto = plainToInstance(UpdateBookMetadataAndLocksDto, {
|
||||
metadata: { goodreadsId: 'manual-id' },
|
||||
lockedFields: ['goodreadsId'],
|
||||
});
|
||||
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts lock-only updates with empty metadata', async () => {
|
||||
const dto = plainToInstance(UpdateBookMetadataAndLocksDto, {
|
||||
metadata: {},
|
||||
lockedFields: ['title'],
|
||||
});
|
||||
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects invalid metadata fields and invalid lock fields', async () => {
|
||||
const dto = plainToInstance(UpdateBookMetadataAndLocksDto, {
|
||||
metadata: { publishedYear: 42 },
|
||||
lockedFields: ['not-a-field'],
|
||||
});
|
||||
|
||||
expect(await validate(dto)).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user