feat: validate and normalize book published year to 1000-2200 range

This commit is contained in:
neon
2026-04-12 17:28:10 -06:00
parent 27f902da1f
commit 13a3ade043
5 changed files with 38 additions and 4 deletions
@@ -4,7 +4,7 @@ import { useSafeHtml } from './useSafeHtml'
vi.mock('dompurify', () => ({
default: {
sanitize: vi.fn((input: string, config: { ALLOWED_TAGS: string[] }) => {
sanitize: vi.fn<(input: string, config: { ALLOWED_TAGS: string[] }) => string>((input: string, config: { ALLOWED_TAGS: string[] }) => {
if (!config?.ALLOWED_TAGS) return ''
// Simulate DOMPurify by stripping disallowed tags and dangerous attributes
return input
@@ -90,6 +90,8 @@ describe('Book DTO validation', () => {
expect((await errorsFor(UpdateBookMetadataDto, { rating: 6 })).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, { authors: ['ok', 1] })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateBookMetadataDto, { language: 'english-too-long' })).length).toBeGreaterThan(0);
expect((await errorsFor(UpdateBookMetadataDto, { isbn10: '12345678901' })).length).toBeGreaterThan(0);
@@ -33,7 +33,7 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsString() @MaxLength(1000) subtitle?: string | null;
@IsOptional() @IsString() description?: string | null;
@IsOptional() @IsString() @MaxLength(500) publisher?: string | null;
@IsOptional() @IsInt() @Min(1) publishedYear?: number | null;
@IsOptional() @IsInt() @Min(1000) @Max(2200) publishedYear?: number | null;
@IsOptional() @IsString() @MaxLength(10) language?: string | null;
@IsOptional() @IsInt() @Min(1) pageCount?: number | null;
@IsOptional() @IsString() @MaxLength(500) seriesName?: string | null;
@@ -530,6 +530,28 @@ describe('MetadataService', () => {
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ publishedYear: null }));
});
it('extractAndSave(mobi) normalizes out-of-range 4-digit years to null before db writes', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
mockParseMobiFile.mockResolvedValue({
title: 'Ancient Book',
description: null,
isbn: 'isbn',
publisher: null,
publishedDate: '0101-01-01',
language: 'en',
authors: ['Author'],
tags: ['Tag'],
});
await service.extractAndSave(34, '/tmp/ancient-book.mobi', 'mobi');
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ publishedYear: null }));
});
it('replaceAuthors normalizes names and deduplicates case-insensitively before db writes', async () => {
const { db, deleteWhere, transaction } = makeDb();
const service = makeService(db);
@@ -44,6 +44,16 @@ interface RelationMutationOptions {
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] as const;
const MAX_RELATION_NAME_LENGTH = 200;
const EXTRACTED_COVER_SOURCE = 'extracted';
const MIN_PUBLISHED_YEAR = 1000;
const MAX_PUBLISHED_YEAR = 2200;
function normalizePublishedYear(year: number | null | undefined): number | null | undefined {
if (year === undefined) return undefined;
if (year === null) return null;
if (!Number.isInteger(year)) return null;
if (year < MIN_PUBLISHED_YEAR || year > MAX_PUBLISHED_YEAR) return null;
return year;
}
@Injectable()
export class MetadataService {
@@ -490,7 +500,7 @@ export class MetadataService {
if (filtered.title !== undefined) scalarFields.title = filtered.title;
if (filtered.description !== undefined) scalarFields.description = filtered.description;
if (filtered.publisher !== undefined) scalarFields.publisher = filtered.publisher;
if (filtered.publishedYear !== undefined) scalarFields.publishedYear = filtered.publishedYear;
if (filtered.publishedYear !== undefined) scalarFields.publishedYear = normalizePublishedYear(filtered.publishedYear);
if (filtered.language !== undefined) scalarFields.language = filtered.language;
if (filtered.audioMetadata?.durationSeconds !== undefined) scalarFields.durationSeconds = filtered.audioMetadata.durationSeconds;
if (filtered.audioMetadata?.chapters !== undefined) scalarFields.chapters = filtered.audioMetadata.chapters;
@@ -545,7 +555,7 @@ export class MetadataService {
if (filtered.isbn10 !== undefined) scalarFields.isbn10 = filtered.isbn10;
if (filtered.isbn13 !== undefined) scalarFields.isbn13 = filtered.isbn13;
if (filtered.publisher !== undefined) scalarFields.publisher = filtered.publisher;
if (filtered.publishedYear !== undefined) scalarFields.publishedYear = filtered.publishedYear;
if (filtered.publishedYear !== undefined) scalarFields.publishedYear = normalizePublishedYear(filtered.publishedYear);
if (filtered.language !== undefined) scalarFields.language = filtered.language;
if (filtered.seriesName !== undefined) scalarFields.seriesName = filtered.seriesName;
if (filtered.seriesIndex !== undefined) scalarFields.seriesIndex = filtered.seriesIndex;