mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fbd7a2e125
ISBNs copied from Amazon carry hyphens, and copied ASINs often start with a zero-width space. The edit tab cut a hyphenated ISBN at its maxlength, Book Dock sent it on for the server to reject, and an invisible character left an ID that looked right but never matched. Clean ISBN, ASIN and provider id input as it is typed or pasted, and apply the same shared rule in the book metadata and Book Dock DTOs so every client, including released iOS builds, gets it. Provider ids keep their punctuation because Google ids and slugs use it, and an over-long value is kept whole so validation still names the field. Closes #1624
409 lines
23 KiB
TypeScript
409 lines
23 KiB
TypeScript
import 'reflect-metadata';
|
|
|
|
import { ValidationPipe } from '@nestjs/common';
|
|
import { plainToInstance } from 'class-transformer';
|
|
import { validate } from 'class-validator';
|
|
import { MetadataProviderKey } from '@bookorbit/types';
|
|
|
|
import { PROVIDER_ID_MAX_LENGTHS } from '../../../common/utils/provider-id.utils';
|
|
|
|
import { BulkBookIdsDto } from './bulk-book-ids.dto';
|
|
import { BulkQuerySelectionDto } from './bulk-query-selection.dto';
|
|
import { BulkSelectionDto } from './bulk-selection.dto';
|
|
import { BulkSetMetadataDto } from './bulk-set-metadata.dto';
|
|
import { BulkSetMetadataLockDto } from './bulk-set-metadata-lock.dto';
|
|
import { BulkSetRatingDto } from './bulk-set-rating.dto';
|
|
import { BulkSetStatusDto } from './bulk-set-status.dto';
|
|
import { BulkUpdateTagsDto } from './bulk-update-tags.dto';
|
|
import { DeleteBooksDto } from './delete-books.dto';
|
|
import { ExportBooksDto } from './export-books.dto';
|
|
import { MetadataExportDto } from './metadata-export.dto';
|
|
import { GetBooksDto } from './get-books.dto';
|
|
import { SaveProgressDto } from './save-progress.dto';
|
|
import { SearchBooksDto } from './search-books.dto';
|
|
import { UpsertAudioProgressDto } from './upsert-audio-progress.dto';
|
|
import { UpdateBookMetadataDto } from './update-book-metadata.dto';
|
|
import { UpdateBookMetadataAndLocksDto } from './update-book-metadata-and-locks.dto';
|
|
import { UpdateBookAddedAtDto } from './update-book-added-at.dto';
|
|
import { UpdatePersonalNoteDto } from './update-personal-note.dto';
|
|
import { UpdateRatingDto } from './update-rating.dto';
|
|
|
|
async function errorsFor<T extends object>(cls: new () => T, value: Record<string, unknown>) {
|
|
const dto = plainToInstance(cls, value);
|
|
return validate(dto);
|
|
}
|
|
|
|
async function strictErrorsFor<T extends object>(cls: new () => T, value: Record<string, unknown>) {
|
|
const dto = plainToInstance(cls, value);
|
|
return validate(dto, { whitelist: true, forbidNonWhitelisted: true });
|
|
}
|
|
|
|
describe('Book DTO validation', () => {
|
|
it('validates bulk selection DTOs for explicit ids and query payloads', async () => {
|
|
expect((await errorsFor(BulkQuerySelectionDto, { libraryId: 5 })).length).toBe(0);
|
|
expect((await errorsFor(BulkQuerySelectionDto, { filter: { type: 'group', join: 'AND', rules: [] } })).length).toBe(0);
|
|
expect((await errorsFor(BulkSelectionDto, { bookIds: [1, 2] })).length).toBe(0);
|
|
expect((await errorsFor(BulkSelectionDto, { query: { libraryId: 5 } })).length).toBe(0);
|
|
expect((await errorsFor(BulkSelectionDto, { query: { filter: { type: 'group', join: 'AND', rules: [] } } })).length).toBe(0);
|
|
expect((await errorsFor(BulkSelectionDto, { query: { sort: [{ field: 'title', dir: 'asc' }] } })).length).toBe(0);
|
|
expect((await errorsFor(BulkSelectionDto, { query: {} })).length).toBe(0);
|
|
expect((await errorsFor(BulkSelectionDto, {})).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(BulkSelectionDto, { bookIds: [] })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(BulkSelectionDto, { bookIds: [0] })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(BulkSelectionDto, { bookIds: ['x'] })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(BulkQuerySelectionDto, { libraryId: 0 })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates bulk book id DTOs in explicit and query modes', async () => {
|
|
expect((await errorsFor(BulkBookIdsDto, { bookIds: [1, 2] })).length).toBe(0);
|
|
expect((await errorsFor(BulkBookIdsDto, { query: { libraryId: 5 } })).length).toBe(0);
|
|
expect((await errorsFor(DeleteBooksDto, { bookIds: [3] })).length).toBe(0);
|
|
expect((await errorsFor(DeleteBooksDto, { query: { filter: { type: 'group', join: 'AND', rules: [] } } })).length).toBe(0);
|
|
expect((await errorsFor(BulkBookIdsDto, {})).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(BulkBookIdsDto, { bookIds: [] })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(DeleteBooksDto, { bookIds: ['x'] })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates bulk action DTOs with bookIds and query selection', async () => {
|
|
expect((await errorsFor(BulkSetStatusDto, { bookIds: [1], status: 'read' })).length).toBe(0);
|
|
expect((await errorsFor(BulkSetStatusDto, { query: { libraryId: 5 }, status: 'read' })).length).toBe(0);
|
|
expect((await errorsFor(BulkSetRatingDto, { bookIds: [1], rating: 4 })).length).toBe(0);
|
|
expect((await errorsFor(BulkSetRatingDto, { query: {}, rating: 4 })).length).toBe(0);
|
|
expect((await errorsFor(BulkSetMetadataDto, { bookIds: [1], field: 'language', value: 'fr' })).length).toBe(0);
|
|
expect((await errorsFor(BulkSetMetadataDto, { query: { libraryId: 5 }, field: 'language', value: 'fr' })).length).toBe(0);
|
|
expect((await errorsFor(BulkUpdateTagsDto, { bookIds: [1], mode: 'add', tags: ['favorite'] })).length).toBe(0);
|
|
expect(
|
|
(await errorsFor(BulkUpdateTagsDto, { query: { filter: { type: 'group', join: 'AND', rules: [] } }, mode: 'replace', tags: ['favorite'] }))
|
|
.length,
|
|
).toBe(0);
|
|
expect((await errorsFor(BulkSetMetadataLockDto, { bookIds: [1], locked: true })).length).toBe(0);
|
|
expect((await errorsFor(BulkSetMetadataLockDto, { query: {}, locked: false })).length).toBe(0);
|
|
});
|
|
|
|
it('validates export options and boolean allFormats flag', async () => {
|
|
expect((await errorsFor(ExportBooksDto, { bookIds: [1], allFormats: true })).length).toBe(0);
|
|
expect((await errorsFor(ExportBooksDto, { bookIds: [1], audioOnly: true })).length).toBe(0);
|
|
expect((await errorsFor(ExportBooksDto, { bookIds: [1], allFormats: 'true' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(ExportBooksDto, { bookIds: [1], audioOnly: 'true' })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates metadata export dto options and scopes', async () => {
|
|
expect(
|
|
(
|
|
await errorsFor(MetadataExportDto, {
|
|
bookIds: [1],
|
|
format: 'csv',
|
|
viewType: 'library',
|
|
sort: [{ field: 'title', dir: 'asc' }],
|
|
options: { includePersonalData: true, includeFilePaths: false, columnsMode: 'canonical' },
|
|
})
|
|
).length,
|
|
).toBe(0);
|
|
expect(
|
|
(
|
|
await errorsFor(MetadataExportDto, {
|
|
query: { libraryId: 3, q: 'dune', sort: [{ field: 'addedAt', dir: 'desc' }] },
|
|
format: 'json',
|
|
viewType: 'smartScope',
|
|
options: { columnsMode: 'visible', visibleColumns: ['title', 'authors'] },
|
|
})
|
|
).length,
|
|
).toBe(0);
|
|
expect((await errorsFor(MetadataExportDto, { bookIds: [1], format: 'xml' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(MetadataExportDto, { bookIds: [1], format: 'csv', options: { columnsMode: 'bad-mode' } })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(MetadataExportDto, { bookIds: [1], format: 'csv', viewType: 'series' })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('coerces GetBooksDto numeric query values and enforces bounds', async () => {
|
|
const valid = plainToInstance(GetBooksDto, { libraryId: '3', page: '0', size: '50', search: 'dune' });
|
|
const validErrors = await validate(valid);
|
|
|
|
expect(validErrors.length).toBe(0);
|
|
expect(valid.libraryId).toBe(3);
|
|
expect(valid.page).toBe(0);
|
|
expect(valid.size).toBe(50);
|
|
|
|
expect((await errorsFor(GetBooksDto, { libraryId: 0 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(GetBooksDto, { libraryId: 3, page: -1 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(GetBooksDto, { libraryId: 3, size: 500 })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates SaveProgressDto percentage bounds and conditional field types', async () => {
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 0 })).length).toBe(0);
|
|
expect(
|
|
(
|
|
await errorsFor(SaveProgressDto, {
|
|
percentage: 100,
|
|
cfi: 'epubcfi(/6/2)',
|
|
pageNumber: 5,
|
|
mediaOverlayFragment: 'OPS/ch1.xhtml#s1',
|
|
mediaOverlaySectionIndex: 3,
|
|
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
|
|
})
|
|
).length,
|
|
).toBe(0);
|
|
|
|
expect((await errorsFor(SaveProgressDto, { percentage: -1 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, cfi: 123 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: 'five' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, mediaOverlayFragment: 123 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, mediaOverlaySectionIndex: -1 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, koreaderProgress: 123 })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('accepts only integer page numbers that fit the reading_progress column', async () => {
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50 })).length).toBe(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: null })).length).toBe(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: 0 })).length).toBe(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: 2147483647 })).length).toBe(0);
|
|
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: 2.5 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: -1 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SaveProgressDto, { percentage: 50, pageNumber: 2147483648 })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('requires non-empty search text and bounds search limit', async () => {
|
|
const valid = plainToInstance(SearchBooksDto, { q: 'dune', limit: '20' });
|
|
const validErrors = await validate(valid);
|
|
|
|
expect(validErrors.length).toBe(0);
|
|
expect(valid.limit).toBe(20);
|
|
|
|
expect((await errorsFor(SearchBooksDto, { q: '' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SearchBooksDto, { q: 'ok', limit: 0 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(SearchBooksDto, { q: 'ok', limit: 21 })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('enforces UpdateBookMetadataDto bounds and array element types', async () => {
|
|
expect(
|
|
(
|
|
await errorsFor(UpdateBookMetadataDto, {
|
|
title: 'Dune',
|
|
publishedDate: '1965-08-01',
|
|
publishedYear: 1965,
|
|
rating: 5,
|
|
seriesIndex: '5.10',
|
|
seriesMemberships: [{ seriesName: 'Dune', seriesIndex: '5.10' }],
|
|
authors: ['Frank Herbert'],
|
|
genres: ['Sci-Fi'],
|
|
tags: ['classic'],
|
|
amazonId: 'B00TEST',
|
|
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345 }],
|
|
})
|
|
).length,
|
|
).toBe(0);
|
|
|
|
expect((await errorsFor(UpdateBookMetadataDto, { rating: 6 })).length).toBeGreaterThan(0);
|
|
expect(
|
|
(await errorsFor(UpdateBookMetadataDto, { communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: -1 }] })).length,
|
|
).toBeGreaterThan(0);
|
|
expect(
|
|
(await errorsFor(UpdateBookMetadataDto, { communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 5.1 }] })).length,
|
|
).toBeGreaterThan(0);
|
|
expect(
|
|
(await errorsFor(UpdateBookMetadataDto, { communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4, ratingCount: -1 }] }))
|
|
.length,
|
|
).toBeGreaterThan(0);
|
|
expect(
|
|
(await errorsFor(UpdateBookMetadataDto, { communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4, ratingCount: 1.5 }] }))
|
|
.length,
|
|
).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { communityRatings: [{ provider: 'unknown', rating: 4 }] })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { publishedYear: 0 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { publishedYear: 999 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { publishedYear: 2201 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { publishedDate: null })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { publishedDate: '1965-08-01' })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { publishedDate: '1965' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { publishedDate: '1965-8-1' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { authors: ['ok', 1] })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { language: 'a'.repeat(101) })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { isbn10: '12345678901' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesIndex: 5.1 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesIndex: '5.10' })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesIndex: '1e2' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesIndex: '1.2.3' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesIndex: '1'.repeat(21) })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesMemberships: [{ seriesName: 'Dune', seriesIndex: 5.1 }] })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('requires an ISO date key when updating the added date', async () => {
|
|
expect((await errorsFor(UpdateBookAddedAtDto, { addedAt: '2020-06-15' })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookAddedAtDto, { addedAt: '2020-6-15' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookAddedAtDto, { addedAt: '2020-06-15T00:00:00.000Z' })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookAddedAtDto, {})).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('accepts aladinId and enforces its length bound', async () => {
|
|
expect((await errorsFor(UpdateBookMetadataDto, { aladinId: '12345' })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { aladinId: null })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { aladinId: 'a'.repeat(21) })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('accepts librofmId and enforces its length bound', async () => {
|
|
expect((await errorsFor(UpdateBookMetadataDto, { librofmId: '9781234567890' })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { librofmId: null })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { librofmId: 'a'.repeat(51) })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('enforces every provider id bound at exactly its declared length', async () => {
|
|
for (const [field, max] of Object.entries(PROVIDER_ID_MAX_LENGTHS)) {
|
|
expect({ field, errors: (await errorsFor(UpdateBookMetadataDto, { [field]: 'a'.repeat(max) })).length }).toEqual({ field, errors: 0 });
|
|
expect({ field, errors: (await errorsFor(UpdateBookMetadataDto, { [field]: 'a'.repeat(max + 1) })).length }).toEqual({ field, errors: 1 });
|
|
}
|
|
});
|
|
|
|
// The client renders these strings verbatim, because they carry the only description of what the
|
|
// server refused. A message that stops naming its field makes the save unfixable. See issue #1015.
|
|
it('names the rejected field and its limit in the validation message', async () => {
|
|
const [error] = await errorsFor(UpdateBookMetadataDto, { amazonId: 'https://www.amazon.com/dp/0345415000' });
|
|
|
|
expect(error?.property).toBe('amazonId');
|
|
expect(Object.values(error?.constraints ?? {}).join(' ')).toBe('amazonId must be shorter than or equal to 20 characters');
|
|
});
|
|
|
|
// Issue #1624: identifiers pasted from Amazon carry hyphens and invisible characters.
|
|
it('cleans pasted identifiers before validating them instead of rejecting them', async () => {
|
|
const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
|
|
|
|
const result = (await pipe.transform(
|
|
{
|
|
metadata: {
|
|
isbn13: '978-3426226223',
|
|
isbn10: '\u200B0-8044-2957-x',
|
|
amazonId: '\u200BB00X47ZVXM',
|
|
audibleId: ' b002v0qk4c\u200E',
|
|
googleBooksId: '\u00A0zyTC-Al_FPjgYC\uFEFF',
|
|
hardcoverId: 'the-name-of-the-wind ',
|
|
},
|
|
lockedFields: [],
|
|
},
|
|
{ type: 'body', metatype: UpdateBookMetadataAndLocksDto, data: undefined },
|
|
)) as UpdateBookMetadataAndLocksDto;
|
|
|
|
expect(result.metadata).toEqual({
|
|
isbn13: '9783426226223',
|
|
isbn10: '080442957X',
|
|
amazonId: 'B00X47ZVXM',
|
|
audibleId: 'B002V0QK4C',
|
|
googleBooksId: 'zyTC-Al_FPjgYC',
|
|
hardcoverId: 'the-name-of-the-wind',
|
|
});
|
|
});
|
|
|
|
it('rejects an identifier that is still too long once cleaned, without truncating it', async () => {
|
|
const dto = plainToInstance(UpdateBookMetadataDto, { isbn13: '978-3426226223-1', isbn10: '0-306-40615-22' });
|
|
const errors = await validate(dto);
|
|
|
|
expect(dto.isbn13).toBe('97834262262231');
|
|
expect(dto.isbn10).toBe('03064061522');
|
|
expect(errors.map((error) => error.property).sort()).toEqual(['isbn10', 'isbn13']);
|
|
});
|
|
|
|
it('leaves cleared and non-string identifiers to the existing validators', async () => {
|
|
const cleared = plainToInstance(UpdateBookMetadataDto, { isbn13: null, amazonId: '', goodreadsId: ' \u200B ' });
|
|
expect(await validate(cleared)).toHaveLength(0);
|
|
expect(cleared).toMatchObject({ isbn13: null, amazonId: '', goodreadsId: '' });
|
|
|
|
const [error] = await errorsFor(UpdateBookMetadataDto, { isbn13: 9783426226223 });
|
|
expect(error?.property).toBe('isbn13');
|
|
expect(error?.constraints).toHaveProperty('isString');
|
|
});
|
|
|
|
it('normalizes a zero page count to null and validates other page count values', async () => {
|
|
// A provider can return a page count of 0 (issue #329); it must normalize to null instead of failing validation.
|
|
const zeroed = plainToInstance(UpdateBookMetadataDto, { pageCount: 0 });
|
|
expect((await validate(zeroed)).length).toBe(0);
|
|
expect(zeroed.pageCount).toBeNull();
|
|
|
|
const valid = plainToInstance(UpdateBookMetadataDto, { pageCount: 320 });
|
|
expect((await validate(valid)).length).toBe(0);
|
|
expect(valid.pageCount).toBe(320);
|
|
|
|
expect((await errorsFor(UpdateBookMetadataDto, { pageCount: null })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { pageCount: -5 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { pageCount: 12.5 })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates nested comicMetadata fields', async () => {
|
|
expect((await errorsFor(UpdateBookMetadataDto, { comicMetadata: { issueNumber: '1', characters: ['Batman'] } })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { comicMetadata: { issueNumber: 1 } })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { comicMetadata: { characters: [1] } })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates inline-edit single-field patches for table view use cases', async () => {
|
|
// Single title patch
|
|
expect((await errorsFor(UpdateBookMetadataDto, { title: 'New Title' })).length).toBe(0);
|
|
// Single series patch
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesName: 'My Series', seriesIndex: '1.5' })).length).toBe(0);
|
|
// Series cleared to null
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesName: null, seriesIndex: null })).length).toBe(0);
|
|
// Language valid 2-letter code
|
|
expect((await errorsFor(UpdateBookMetadataDto, { language: 'en' })).length).toBe(0);
|
|
// Language full name (e.g. from epub/provider)
|
|
expect((await errorsFor(UpdateBookMetadataDto, { language: 'Spanish; Castilian' })).length).toBe(0);
|
|
// Language cleared to null
|
|
expect((await errorsFor(UpdateBookMetadataDto, { language: null })).length).toBe(0);
|
|
// Rating null (clear rating)
|
|
expect((await errorsFor(UpdateBookMetadataDto, { rating: null })).length).toBe(0);
|
|
// Rating valid range
|
|
expect((await errorsFor(UpdateBookMetadataDto, { rating: 1 })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { rating: 5 })).length).toBe(0);
|
|
// Authors array single-field patch
|
|
expect((await errorsFor(UpdateBookMetadataDto, { authors: ['Author One'] })).length).toBe(0);
|
|
// Genres/tags single-field patch
|
|
expect((await errorsFor(UpdateBookMetadataDto, { genres: ['Fantasy'] })).length).toBe(0);
|
|
expect((await errorsFor(UpdateBookMetadataDto, { tags: ['to-read'] })).length).toBe(0);
|
|
// Empty payload (all fields optional)
|
|
expect((await errorsFor(UpdateBookMetadataDto, {})).length).toBe(0);
|
|
// seriesIndex must be a numeric label
|
|
expect((await errorsFor(UpdateBookMetadataDto, { seriesIndex: 'not-a-number' })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('rejects legacy top-level audiobook fields and accepts nested audioMetadata', async () => {
|
|
const legacyErrors = await strictErrorsFor(UpdateBookMetadataDto, {
|
|
narrators: ['Narrator One'],
|
|
durationSeconds: 3600,
|
|
abridged: true,
|
|
chapters: [{ title: 'Chapter 1', startMs: 0 }],
|
|
});
|
|
|
|
expect(legacyErrors.length).toBeGreaterThan(0);
|
|
expect(legacyErrors.map((e) => e.property)).toEqual(expect.arrayContaining(['narrators', 'durationSeconds', 'abridged', 'chapters']));
|
|
|
|
const nestedErrors = await strictErrorsFor(UpdateBookMetadataDto, {
|
|
audioMetadata: {
|
|
narrators: ['Narrator One'],
|
|
durationSeconds: 3600,
|
|
abridged: true,
|
|
chapters: [{ title: 'Chapter 1', startMs: 0 }],
|
|
},
|
|
});
|
|
|
|
expect(nestedErrors.length).toBe(0);
|
|
});
|
|
|
|
it('allows optional null rating reset and rejects out-of-range values', async () => {
|
|
expect((await errorsFor(UpdateRatingDto, { rating: null })).length).toBe(0);
|
|
expect((await errorsFor(UpdateRatingDto, { rating: 3 })).length).toBe(0);
|
|
expect((await errorsFor(UpdateRatingDto, { rating: 0 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpdateRatingDto, { rating: 6 })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates personal note updates', async () => {
|
|
expect((await errorsFor(UpdatePersonalNoteDto, { note: 'My private review' })).length).toBe(0);
|
|
expect((await errorsFor(UpdatePersonalNoteDto, { note: null })).length).toBe(0);
|
|
expect((await errorsFor(UpdatePersonalNoteDto, { note: 'a'.repeat(10001) })).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('validates audiobook progress bounds for percentage and position', async () => {
|
|
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 0, currentFileId: 1, positionSeconds: 0 })).length).toBe(0);
|
|
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 100, currentFileId: 2, positionSeconds: 120.5 })).length).toBe(0);
|
|
|
|
expect((await errorsFor(UpsertAudioProgressDto, { percentage: -1, currentFileId: 1, positionSeconds: 0 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 101, currentFileId: 1, positionSeconds: 0 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 10, currentFileId: 0, positionSeconds: 0 })).length).toBeGreaterThan(0);
|
|
expect((await errorsFor(UpsertAudioProgressDto, { percentage: 10, currentFileId: 1, positionSeconds: -5 })).length).toBeGreaterThan(0);
|
|
});
|
|
});
|