From e6186c09df9167afd7714d9f4635790947da988d Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:26:37 -0600 Subject: [PATCH] test: add comprehensive unit tests and increase coverage thresholds --- .github/workflows/ci.yml | 2 +- .../decorators/common-decorators.test.ts | 42 ++ .../decorators/current-user.decorator.test.ts | 49 ++ .../filters/http-exception.filter.test.ts | 86 +++ .../interceptors/audit.interceptor.test.ts | 102 ++++ server/src/common/logger.config.test.ts | 83 +++ .../src/common/utils/user-avatar-url.test.ts | 44 ++ .../annotation/annotation.controller.test.ts | 81 +++ .../annotation/annotation.module.test.ts | 21 + .../dto/annotation-dto-validation.test.ts | 49 ++ .../dto/app-settings-dto-validation.test.ts | 68 +++ .../modules/audit/audit-cleanup.job.test.ts | 24 + .../modules/audit/audit.controller.test.ts | 57 ++ server/src/modules/audit/audit.module.test.ts | 25 + .../modules/audit/audit.repository.test.ts | 106 ++++ .../src/modules/audit/audit.service.test.ts | 198 +++++++ .../audit/dto/audit-log-query.dto.test.ts | 45 ++ .../author-enrichment-config.service.test.ts | 104 ++++ ...or-enrichment-orchestrator.service.test.ts | 70 +++ .../authors/author-enrichment-reasons.test.ts | 14 + .../author-enrichment-session.service.test.ts | 32 ++ .../authors/author-enrichment.gateway.test.ts | 112 +++- .../author-enrichment.repository.test.ts | 93 ++++ .../author-image-storage.service.test.ts | 209 +++++++ .../authors/authors.controller.test.ts | 299 ++++++++++ .../src/modules/authors/authors.controller.ts | 4 +- .../modules/authors/authors.module.test.ts | 32 ++ .../authors/authors.repository.test.ts | 394 +++++++++++++- .../modules/authors/authors.service.test.ts | 276 ++++++++++ .../dto/authors-dto-validation.test.ts | 125 +++++ .../author-metadata-fetch.service.test.ts | 83 +++ .../authors/metadata/constants.test.ts | 8 + .../metadata/provider-registry.test.ts | 60 ++ .../author-metadata-provider.test.ts | 40 ++ .../book-bucket-events.service.test.ts | 22 + .../book-bucket-finalize.service.test.ts | 462 +++++++++++++++- .../book-bucket-ingest.service.test.ts | 191 ++++++- .../book-bucket-metadata.service.test.ts | 224 ++++++++ .../book-bucket-watcher.service.test.ts | 143 +++++ .../book-bucket.controller.test.ts | 154 ++++++ .../book-bucket/book-bucket.dto.test.ts | 76 +++ .../book-bucket/book-bucket.gateway.test.ts | 74 +++ .../book-bucket/book-bucket.module.test.ts | 42 ++ .../book-bucket.repository.test.ts | 205 +++++++ .../book-bucket/book-bucket.service.test.ts | 217 ++++++++ server/src/modules/book-bucket/dto/index.ts | 1 + ...book-metadata-fetch-config.service.test.ts | 84 +++ ...metadata-fetch-eligibility.service.test.ts | 21 + ...etadata-fetch-orchestrator.service.test.ts | 444 +++++++++++++++ ...ok-metadata-fetch-queue.repository.test.ts | 164 ++++++ .../book-metadata-fetch.controller.test.ts | 51 ++ .../book-metadata-fetch.gateway.test.ts | 118 ++++ .../book-metadata-fetch.module.test.ts | 65 +++ .../update-config.dto.test.ts | 80 +++ .../book-metadata-lock.dto.test.ts | 28 + .../book-metadata-lock.module.test.ts | 17 + .../book-metadata-lock.repository.test.ts | 91 ++++ .../modules/book/book-read.service.test.ts | 73 +++ .../src/modules/book/book.controller.test.ts | 207 +++++++ server/src/modules/book/book.module.test.ts | 11 + .../src/modules/book/book.repository.test.ts | 244 +++++++++ server/src/modules/book/book.service.test.ts | 511 ++++++++++++++++++ .../modules/book/dto/book-detail.dto.test.ts | 41 ++ .../book/dto/book-dto-validation.test.ts | 11 + .../bookmark/bookmark-response.dto.test.ts | 40 ++ .../modules/bookmark/bookmark.module.test.ts | 20 + .../collection/collection.controller.test.ts | 64 +++ .../collection/collection.module.test.ts | 21 + .../collection/collection.repository.test.ts | 55 +- .../collection/collection.service.test.ts | 64 +++ .../modules/cover/cover.controller.test.ts | 83 +++ server/src/modules/cover/cover.module.test.ts | 32 ++ .../dto/upload-cover-from-url.dto.test.ts | 16 + .../duckduckgo-cover-provider.test.ts | 118 ++++ .../providers/duckduckgo-cover-provider.ts | 4 +- .../email/email-book-read.repository.test.ts | 115 ++++ .../email/email-provider.controller.test.ts | 57 +- .../email-recipient-group.controller.test.ts | 42 ++ .../email/email-recipient.controller.test.ts | 28 + .../email/email-template.controller.test.ts | 35 ++ .../decorators/kobo-device.decorator.test.ts | 41 ++ .../kobo/dto/kobo-dto-validation.test.ts | 47 ++ .../kobo/guards/kobo-token.guard.test.ts | 100 ++++ .../modules/kobo/kobo-auth.controller.test.ts | 38 ++ .../kobo/kobo-device.controller.test.ts | 81 +++ .../modules/kobo/kobo-store-resources.test.ts | 17 + .../modules/kobo/kobo-sync.controller.test.ts | 158 ++++++ .../modules/kobo/kobo-user.controller.test.ts | 82 +++ server/src/modules/kobo/kobo.module.test.ts | 40 ++ .../services/kepubify-binary.service.test.ts | 106 ++++ .../services/kobo-book-access.service.test.ts | 16 + .../kobo/services/kobo-device.service.test.ts | 114 ++++ .../kobo/services/kobo-device.service.ts | 3 +- .../services/kobo-download.service.test.ts | 243 +++++++++ .../kobo/services/kobo-proxy.service.test.ts | 109 ++++ .../kobo-reading-state.service.test.ts | 209 +++++++ .../services/kobo-settings.service.test.ts | 158 ++++++ .../kobo/services/kobo-settings.service.ts | 4 + .../kobo/services/kobo-sync.service.test.ts | 426 +++++++++++++++ .../services/kobo-thumbnail.service.test.ts | 111 ++++ .../src/modules/lens/lens.controller.test.ts | 52 ++ .../src/modules/lens/lens.repository.test.ts | 61 +++ .../fetch-with-throttle.test.ts | 67 +++ .../metadata-fetch/fetch-with-throttle.ts | 21 +- .../metadata-fetch.repository.test.ts | 74 +++ .../provider-throttle.error.test.ts | 17 + .../audible/audible.provider.test.ts | 206 +++++++ .../audnexus/audnexus.provider.test.ts | 84 +++ .../comicvine/comicvine.mapper.test.ts | 116 ++++ .../providers/comicvine/comicvine.mapper.ts | 4 +- .../providers/metadata-search-params.test.ts | 26 + .../comic-metadata.repository.test.ts | 73 +++ .../extractors/audio.extractor.test.ts | 95 +++- .../extractors/format-extractors.test.ts | 285 ++++++++++ .../modules/metadata/lib/pdf-cover.test.ts | 52 ++ .../metadata/metadata-events.service.test.ts | 13 + .../modules/metadata/metadata.service.test.ts | 342 +++++++++++- .../booklore/booklore-connector.test.ts | 68 +++ .../booklore/booklore-source.adapter.test.ts | 276 ++++++++++ .../adapters/source-adapter.registry.test.ts | 36 ++ .../dto/migration-dto-validation.test.ts | 72 +++ .../migration/executor/cover.importer.test.ts | 118 ++++ .../migration/executor/executor-utils.test.ts | 174 ++++++ .../migration-executor.service.core.test.ts | 310 +++++++++++ .../migration-executor.service.test.ts | 125 +++++ .../migration-import.repository.test.ts | 432 +++++++++++++++ .../executor/migration-import.repository.ts | 2 + .../executor/shared-overlays.importer.test.ts | 113 ++++ .../executor/user-state.importer.test.ts | 407 ++++++++++++++ .../migration-profile.service.test.ts | 373 +++++++++---- .../migration-progress.gateway.test.ts | 181 +++++++ .../migration-source.service.test.ts | 413 +++++++++----- .../migration/migration.controller.test.ts | 139 +++++ .../migration/migration.repository.test.ts | 267 +++++++++ .../migration/migration.service.test.ts | 264 +++++++++ .../planner/matching.service.test.ts | 136 ++++- .../migration/planner/planner.service.test.ts | 222 ++++++++ .../migration-reporting.service.test.ts | 213 ++++++++ .../narrator/narrator.repository.test.ts | 112 ++++ .../opds/__tests__/opds-book.service.test.ts | 268 ++++++++- .../modules/opds/dto/opds-user.dto.test.ts | 37 ++ .../modules/opds/opds-user.controller.test.ts | 24 + .../modules/opds/opds-user.decorator.test.ts | 36 ++ .../src/modules/opds/opds.controller.test.ts | 222 +++++++- server/src/modules/opds/opds.module.test.ts | 30 + .../src/modules/path/path.controller.test.ts | 25 + server/src/modules/path/path.module.test.ts | 12 + server/src/modules/path/path.service.test.ts | 65 +++ .../modules/reader/cbz/cbz.controller.test.ts | 44 ++ .../src/modules/reader/cbz/cbz.module.test.ts | 16 + .../modules/reader/cbz/cbz.service.test.ts | 260 +++++++++ .../reader/epub/epub.controller.test.ts | 90 +++ .../modules/reader/epub/epub.controller.ts | 36 +- .../modules/reader/epub/epub.module.test.ts | 18 + .../modules/reader/epub/epub.service.test.ts | 365 +++++++++++++ .../dto/save-reading-session.dto.test.ts | 35 ++ .../reading-session.controller.test.ts | 23 + .../reading-session.module.test.ts | 17 + .../scanner/file-watcher.lifecycle.test.ts | 251 +++++++++ .../src/modules/scanner/scan.gateway.test.ts | 133 ++++- .../scanner/scanner.controller.test.ts | 21 + .../modules/scanner/scanner.module.test.ts | 44 ++ .../scanner/scanner.repository.test.ts | 249 +++++++++ .../modules/scanner/scanner.service.test.ts | 135 +++++ .../statistics/statistics.controller.test.ts | 67 +++ .../statistics/statistics.module.test.ts | 15 + .../statistics/statistics.repository.test.ts | 233 ++++++++ .../statistics.service.additional.test.ts | 183 +++++++ .../dto/user-statistics-dtos.test.ts | 74 +++ .../user-statistics-aggregation.job.test.ts | 55 ++ .../user-statistics.controller.test.ts | 72 +++ .../user-statistics.module.test.ts | 20 + .../user-statistics.repository.test.ts | 347 ++++++++++++ .../user-statistics.service.test.ts | 166 ++++++ .../user/user-avatar-storage.service.test.ts | 67 +++ .../src/modules/user/user.controller.test.ts | 142 ++++- server/src/modules/user/user.dto.test.ts | 7 + .../src/modules/user/user.repository.test.ts | 147 +++++ server/src/modules/user/user.service.test.ts | 129 +++++ server/vitest.config.ts | 22 +- 180 files changed, 19883 insertions(+), 366 deletions(-) create mode 100644 server/src/common/decorators/common-decorators.test.ts create mode 100644 server/src/common/decorators/current-user.decorator.test.ts create mode 100644 server/src/common/filters/http-exception.filter.test.ts create mode 100644 server/src/common/interceptors/audit.interceptor.test.ts create mode 100644 server/src/common/logger.config.test.ts create mode 100644 server/src/common/utils/user-avatar-url.test.ts create mode 100644 server/src/modules/annotation/annotation.controller.test.ts create mode 100644 server/src/modules/annotation/annotation.module.test.ts create mode 100644 server/src/modules/annotation/dto/annotation-dto-validation.test.ts create mode 100644 server/src/modules/app-settings/dto/app-settings-dto-validation.test.ts create mode 100644 server/src/modules/audit/audit-cleanup.job.test.ts create mode 100644 server/src/modules/audit/audit.controller.test.ts create mode 100644 server/src/modules/audit/audit.module.test.ts create mode 100644 server/src/modules/audit/audit.repository.test.ts create mode 100644 server/src/modules/audit/audit.service.test.ts create mode 100644 server/src/modules/audit/dto/audit-log-query.dto.test.ts create mode 100644 server/src/modules/authors/author-enrichment-config.service.test.ts create mode 100644 server/src/modules/authors/author-enrichment-reasons.test.ts create mode 100644 server/src/modules/authors/author-enrichment-session.service.test.ts create mode 100644 server/src/modules/authors/authors.controller.test.ts create mode 100644 server/src/modules/authors/authors.module.test.ts create mode 100644 server/src/modules/authors/dto/authors-dto-validation.test.ts create mode 100644 server/src/modules/authors/metadata/constants.test.ts create mode 100644 server/src/modules/authors/metadata/provider-registry.test.ts create mode 100644 server/src/modules/authors/metadata/providers/author-metadata-provider.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket-events.service.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket-watcher.service.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket.controller.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket.dto.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket.gateway.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket.module.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket.repository.test.ts create mode 100644 server/src/modules/book-bucket/book-bucket.service.test.ts create mode 100644 server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts create mode 100644 server/src/modules/book-metadata-fetch/book-metadata-fetch.gateway.test.ts create mode 100644 server/src/modules/book-metadata-fetch/book-metadata-fetch.module.test.ts create mode 100644 server/src/modules/book-metadata-fetch/update-config.dto.test.ts create mode 100644 server/src/modules/book-metadata-lock/book-metadata-lock.dto.test.ts create mode 100644 server/src/modules/book-metadata-lock/book-metadata-lock.module.test.ts create mode 100644 server/src/modules/book-metadata-lock/book-metadata-lock.repository.test.ts create mode 100644 server/src/modules/book/book-read.service.test.ts create mode 100644 server/src/modules/book/dto/book-detail.dto.test.ts create mode 100644 server/src/modules/bookmark/bookmark-response.dto.test.ts create mode 100644 server/src/modules/bookmark/bookmark.module.test.ts create mode 100644 server/src/modules/collection/collection.module.test.ts create mode 100644 server/src/modules/cover/cover.controller.test.ts create mode 100644 server/src/modules/cover/cover.module.test.ts create mode 100644 server/src/modules/cover/dto/upload-cover-from-url.dto.test.ts create mode 100644 server/src/modules/cover/providers/duckduckgo-cover-provider.test.ts create mode 100644 server/src/modules/email/email-book-read.repository.test.ts create mode 100644 server/src/modules/kobo/decorators/kobo-device.decorator.test.ts create mode 100644 server/src/modules/kobo/dto/kobo-dto-validation.test.ts create mode 100644 server/src/modules/kobo/guards/kobo-token.guard.test.ts create mode 100644 server/src/modules/kobo/kobo-auth.controller.test.ts create mode 100644 server/src/modules/kobo/kobo-device.controller.test.ts create mode 100644 server/src/modules/kobo/kobo-store-resources.test.ts create mode 100644 server/src/modules/kobo/kobo-sync.controller.test.ts create mode 100644 server/src/modules/kobo/kobo-user.controller.test.ts create mode 100644 server/src/modules/kobo/kobo.module.test.ts create mode 100644 server/src/modules/kobo/services/kepubify-binary.service.test.ts create mode 100644 server/src/modules/kobo/services/kobo-device.service.test.ts create mode 100644 server/src/modules/kobo/services/kobo-download.service.test.ts create mode 100644 server/src/modules/kobo/services/kobo-proxy.service.test.ts create mode 100644 server/src/modules/kobo/services/kobo-reading-state.service.test.ts create mode 100644 server/src/modules/kobo/services/kobo-settings.service.test.ts create mode 100644 server/src/modules/kobo/services/kobo-sync.service.test.ts create mode 100644 server/src/modules/kobo/services/kobo-thumbnail.service.test.ts create mode 100644 server/src/modules/lens/lens.controller.test.ts create mode 100644 server/src/modules/lens/lens.repository.test.ts create mode 100644 server/src/modules/metadata-fetch/fetch-with-throttle.test.ts create mode 100644 server/src/modules/metadata-fetch/metadata-fetch.repository.test.ts create mode 100644 server/src/modules/metadata-fetch/provider-throttle.error.test.ts create mode 100644 server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts create mode 100644 server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.test.ts create mode 100644 server/src/modules/metadata-fetch/providers/metadata-search-params.test.ts create mode 100644 server/src/modules/metadata/comic-metadata.repository.test.ts create mode 100644 server/src/modules/metadata/extractors/format-extractors.test.ts create mode 100644 server/src/modules/metadata/lib/pdf-cover.test.ts create mode 100644 server/src/modules/metadata/metadata-events.service.test.ts create mode 100644 server/src/modules/migration/adapters/source-adapter.registry.test.ts create mode 100644 server/src/modules/migration/dto/migration-dto-validation.test.ts create mode 100644 server/src/modules/migration/executor/cover.importer.test.ts create mode 100644 server/src/modules/migration/executor/executor-utils.test.ts create mode 100644 server/src/modules/migration/executor/migration-executor.service.core.test.ts create mode 100644 server/src/modules/migration/executor/migration-import.repository.test.ts create mode 100644 server/src/modules/migration/executor/shared-overlays.importer.test.ts create mode 100644 server/src/modules/migration/executor/user-state.importer.test.ts create mode 100644 server/src/modules/migration/migration-progress.gateway.test.ts create mode 100644 server/src/modules/migration/migration.controller.test.ts create mode 100644 server/src/modules/migration/migration.repository.test.ts create mode 100644 server/src/modules/migration/planner/planner.service.test.ts create mode 100644 server/src/modules/migration/reporting/migration-reporting.service.test.ts create mode 100644 server/src/modules/narrator/narrator.repository.test.ts create mode 100644 server/src/modules/opds/dto/opds-user.dto.test.ts create mode 100644 server/src/modules/opds/opds-user.controller.test.ts create mode 100644 server/src/modules/opds/opds-user.decorator.test.ts create mode 100644 server/src/modules/opds/opds.module.test.ts create mode 100644 server/src/modules/path/path.controller.test.ts create mode 100644 server/src/modules/path/path.module.test.ts create mode 100644 server/src/modules/path/path.service.test.ts create mode 100644 server/src/modules/reader/cbz/cbz.controller.test.ts create mode 100644 server/src/modules/reader/cbz/cbz.module.test.ts create mode 100644 server/src/modules/reader/cbz/cbz.service.test.ts create mode 100644 server/src/modules/reader/epub/epub.controller.test.ts create mode 100644 server/src/modules/reader/epub/epub.module.test.ts create mode 100644 server/src/modules/reader/epub/epub.service.test.ts create mode 100644 server/src/modules/reading-session/dto/save-reading-session.dto.test.ts create mode 100644 server/src/modules/reading-session/reading-session.controller.test.ts create mode 100644 server/src/modules/reading-session/reading-session.module.test.ts create mode 100644 server/src/modules/scanner/file-watcher.lifecycle.test.ts create mode 100644 server/src/modules/scanner/scanner.controller.test.ts create mode 100644 server/src/modules/scanner/scanner.module.test.ts create mode 100644 server/src/modules/scanner/scanner.repository.test.ts create mode 100644 server/src/modules/statistics/statistics.controller.test.ts create mode 100644 server/src/modules/statistics/statistics.module.test.ts create mode 100644 server/src/modules/statistics/statistics.repository.test.ts create mode 100644 server/src/modules/statistics/statistics.service.additional.test.ts create mode 100644 server/src/modules/user-statistics/dto/user-statistics-dtos.test.ts create mode 100644 server/src/modules/user-statistics/user-statistics-aggregation.job.test.ts create mode 100644 server/src/modules/user-statistics/user-statistics.controller.test.ts create mode 100644 server/src/modules/user-statistics/user-statistics.module.test.ts create mode 100644 server/src/modules/user-statistics/user-statistics.repository.test.ts create mode 100644 server/src/modules/user/user-avatar-storage.service.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 538106c6e..dd85f6044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,7 +168,7 @@ jobs: JUNIT_OUTPUT: ${{ github.workspace }}/test-results/${{ matrix.package }}/junit.xml run: | if [[ "$PACKAGE" == "server" ]]; then - pnpm --filter server exec vitest --run --reporter=default --reporter=github-actions --reporter=junit --outputFile.junit="$JUNIT_OUTPUT" + pnpm --filter server exec vitest --run --coverage --reporter=default --reporter=github-actions --reporter=junit --outputFile.junit="$JUNIT_OUTPUT" else pnpm --filter client exec vitest --run --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT" fi diff --git a/server/src/common/decorators/common-decorators.test.ts b/server/src/common/decorators/common-decorators.test.ts new file mode 100644 index 000000000..f0747b1dd --- /dev/null +++ b/server/src/common/decorators/common-decorators.test.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata'; + +import { AuditAction, AuditResource, Permission } from '@projectx/types'; + +import { AllowDefaultPassword, ALLOW_DEFAULT_PASSWORD_KEY } from './allow-default-password.decorator'; +import { Auditable, AUDITABLE_KEY } from './auditable.decorator'; +import { Public, IS_PUBLIC_KEY } from './public.decorator'; +import { RequireLibraryAccess, LIBRARY_ACCESS_KEY } from './require-library-access.decorator'; +import { RequirePermission, PERMISSION_KEY } from './require-permission.decorator'; + +describe('common decorators', () => { + it('stores metadata for auth, permission, library access, and audit options', () => { + const auditableOptions = { + action: AuditAction.LensCreate, + resource: AuditResource.Lens, + description: 'Created lens', + }; + + class DecoratedController { + @Public() + open() {} + + @AllowDefaultPassword() + allowDefaultPassword() {} + + @RequirePermission(Permission.ManageUsers) + withPermission() {} + + @RequireLibraryAccess('viewer') + withLibraryAccess() {} + + @Auditable(auditableOptions) + audited() {} + } + + expect(Reflect.getMetadata(IS_PUBLIC_KEY, DecoratedController.prototype.open)).toBe(true); + expect(Reflect.getMetadata(ALLOW_DEFAULT_PASSWORD_KEY, DecoratedController.prototype.allowDefaultPassword)).toBe(true); + expect(Reflect.getMetadata(PERMISSION_KEY, DecoratedController.prototype.withPermission)).toBe(Permission.ManageUsers); + expect(Reflect.getMetadata(LIBRARY_ACCESS_KEY, DecoratedController.prototype.withLibraryAccess)).toBe('viewer'); + expect(Reflect.getMetadata(AUDITABLE_KEY, DecoratedController.prototype.audited)).toEqual(auditableOptions); + }); +}); diff --git a/server/src/common/decorators/current-user.decorator.test.ts b/server/src/common/decorators/current-user.decorator.test.ts new file mode 100644 index 000000000..b5de43146 --- /dev/null +++ b/server/src/common/decorators/current-user.decorator.test.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; + +import type { ExecutionContext } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +import type { RequestUser } from '../types/request-user'; +import { CurrentUser } from './current-user.decorator'; + +function makeUser(): RequestUser { + return { + id: 99, + username: 'reader', + name: 'Reader', + email: null, + active: true, + isSuperuser: false, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + }; +} + +describe('CurrentUser decorator', () => { + it('registers a route param factory that extracts request.user', () => { + const user = makeUser(); + const context = { + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + } as unknown as ExecutionContext; + + class TestController { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + method(@CurrentUser() _currentUser: RequestUser) {} + } + + const metadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestController, 'method') as Record< + string, + { factory?: (data: unknown, context: ExecutionContext) => RequestUser } + >; + const paramEntry = Object.values(metadata)[0]; + + expect(typeof paramEntry.factory).toBe('function'); + expect(paramEntry.factory?.(undefined, context)).toEqual(user); + }); +}); diff --git a/server/src/common/filters/http-exception.filter.test.ts b/server/src/common/filters/http-exception.filter.test.ts new file mode 100644 index 000000000..4f369bdf4 --- /dev/null +++ b/server/src/common/filters/http-exception.filter.test.ts @@ -0,0 +1,86 @@ +import type { ArgumentsHost } from '@nestjs/common'; +import { BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common'; + +import { GlobalExceptionFilter } from './http-exception.filter'; + +function makeHost() { + const send = vi.fn(); + const status = vi.fn().mockReturnValue({ send }); + const reply = { status }; + const request = { url: '/api/books/1', id: 'req-123' }; + + const host = { + switchToHttp: () => ({ + getResponse: () => reply, + getRequest: () => request, + }), + } as unknown as ArgumentsHost; + + return { host, status, send }; +} + +describe('GlobalExceptionFilter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serializes HttpException payloads into standard error shape', () => { + const filter = new GlobalExceptionFilter(); + const { host, status, send } = makeHost(); + + filter.catch(new BadRequestException('invalid query'), host); + + expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.BAD_REQUEST, + message: 'invalid query', + path: '/api/books/1', + requestId: 'req-123', + }), + ); + }); + + it('falls back to statusCode/message fields on non-HttpException values', () => { + const filter = new GlobalExceptionFilter(); + const { host, status, send } = makeHost(); + + filter.catch({ statusCode: 422, message: 'unprocessable input' }, host); + + expect(status).toHaveBeenCalledWith(422); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 422, + message: 'Internal server error', + }), + ); + }); + + it('logs server errors and returns 500 for unknown exceptions', () => { + const loggerSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const filter = new GlobalExceptionFilter(); + const { host, status, send } = makeHost(); + const exception = new Error('db down'); + + filter.catch(exception, host); + + expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Internal server error', + }), + ); + expect(loggerSpy).toHaveBeenCalledWith(exception); + }); + + it('does not log client errors', () => { + const loggerSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const filter = new GlobalExceptionFilter(); + const { host } = makeHost(); + + filter.catch(new HttpException({ message: 'conflict' }, HttpStatus.CONFLICT), host); + + expect(loggerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/common/interceptors/audit.interceptor.test.ts b/server/src/common/interceptors/audit.interceptor.test.ts new file mode 100644 index 000000000..893e79330 --- /dev/null +++ b/server/src/common/interceptors/audit.interceptor.test.ts @@ -0,0 +1,102 @@ +import type { CallHandler, ExecutionContext } from '@nestjs/common'; +import { firstValueFrom, of } from 'rxjs'; + +import { AUDIT_EVENT } from '../../modules/audit/audit-events.service'; +import { AUDITABLE_KEY } from '../decorators/auditable.decorator'; +import { AuditInterceptor } from './audit.interceptor'; + +function makeContext(request: Record): ExecutionContext { + return { + getHandler: vi.fn(), + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; +} + +describe('AuditInterceptor', () => { + it('passes through untouched when endpoint is not auditable', async () => { + const reflector = { + get: vi.fn().mockReturnValue(undefined), + }; + const auditEvents = { + emit: vi.fn(), + }; + const interceptor = new AuditInterceptor(reflector as never, auditEvents as never); + const next: CallHandler = { + handle: vi.fn().mockReturnValue(of({ ok: true })), + }; + + const response = await firstValueFrom(interceptor.intercept(makeContext({ ip: '127.0.0.1' }), next)); + + expect(response).toEqual({ ok: true }); + expect(auditEvents.emit).not.toHaveBeenCalled(); + }); + + it('emits audit events with user, resource id, and dynamic description', async () => { + const reflector = { + get: vi.fn().mockImplementation((key: string) => { + if (key !== AUDITABLE_KEY) return undefined; + return { + action: 'lens.create', + resource: 'lens', + getResourceId: (_req: unknown, body: { id: number }) => body.id, + description: (_req: unknown, body: { name: string }) => `Created lens ${body.name}`, + }; + }), + }; + const auditEvents = { + emit: vi.fn(), + }; + const interceptor = new AuditInterceptor(reflector as never, auditEvents as never); + const next: CallHandler = { + handle: vi.fn().mockReturnValue(of({ id: 22, name: 'Favorites' })), + }; + const context = makeContext({ + ip: '10.0.0.8', + user: { + id: 7, + username: 'reader', + }, + }); + + await firstValueFrom(interceptor.intercept(context, next)); + + expect(auditEvents.emit).toHaveBeenCalledWith(AUDIT_EVENT, { + userId: 7, + actorUsername: 'reader', + action: 'lens.create', + resource: 'lens', + resourceId: 22, + description: 'Created lens Favorites', + ip: '10.0.0.8', + }); + }); + + it('falls back to system actor when request user is absent', async () => { + const reflector = { + get: vi.fn().mockReturnValue({ + action: 'system.event', + description: 'System audit event', + }), + }; + const auditEvents = { + emit: vi.fn(), + }; + const interceptor = new AuditInterceptor(reflector as never, auditEvents as never); + const next: CallHandler = { + handle: vi.fn().mockReturnValue(of({})), + }; + + await firstValueFrom(interceptor.intercept(makeContext({ ip: '::1' }), next)); + + expect(auditEvents.emit).toHaveBeenCalledWith( + AUDIT_EVENT, + expect.objectContaining({ + userId: null, + actorUsername: 'system', + ip: '::1', + }), + ); + }); +}); diff --git a/server/src/common/logger.config.test.ts b/server/src/common/logger.config.test.ts new file mode 100644 index 000000000..7ddb0d985 --- /dev/null +++ b/server/src/common/logger.config.test.ts @@ -0,0 +1,83 @@ +type LoggerConfigModule = typeof import('./logger.config'); + +async function loadLoggerConfig(nodeEnv: 'development' | 'production', logLevel?: string): Promise { + vi.resetModules(); + vi.stubEnv('NODE_ENV', nodeEnv); + if (logLevel === undefined) { + vi.unstubAllEnvs(); + vi.stubEnv('NODE_ENV', nodeEnv); + } else { + vi.stubEnv('LOG_LEVEL', logLevel); + } + + const mod = await import('./logger.config'); + return mod.loggerConfig; +} + +describe('loggerConfig', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('uses debug level and pretty transport by default in development', async () => { + const config = await loadLoggerConfig('development'); + + expect(config.pinoHttp?.level).toBe('debug'); + expect(config.pinoHttp).toEqual( + expect.objectContaining({ + transport: expect.objectContaining({ + target: 'pino-pretty', + }), + }), + ); + }); + + it('honors LOG_LEVEL override and omits pretty transport in production', async () => { + const config = await loadLoggerConfig('production', 'warn'); + + expect(config.pinoHttp?.level).toBe('warn'); + expect(config.pinoHttp).not.toHaveProperty('transport'); + }); + + it('formats HTTP success/error messages and chooses log levels by status/error', async () => { + const config = await loadLoggerConfig('development'); + const pinoHttp = config.pinoHttp!; + + expect(pinoHttp.customSuccessMessage?.({ method: 'GET', url: '/api/books' } as never, { statusCode: 201 } as never, 7.2)).toBe( + '[HTTP] GET /api/books 201 +7ms', + ); + + expect(pinoHttp.customErrorMessage?.({ method: 'POST', url: '/api/books' } as never, { statusCode: 500 } as never, new Error('boom'))).toBe( + '[HTTP] POST /api/books 500 - boom', + ); + + expect(pinoHttp.customLogLevel?.({} as never, { statusCode: 200 } as never)).toBe('debug'); + expect(pinoHttp.customLogLevel?.({} as never, { statusCode: 404 } as never)).toBe('warn'); + expect(pinoHttp.customLogLevel?.({} as never, { statusCode: 503 } as never)).toBe('error'); + expect(pinoHttp.customLogLevel?.({} as never, { statusCode: 200 } as never, new Error('fail'))).toBe('error'); + }); + + it('filters framework context logs while keeping regular logs', async () => { + const config = await loadLoggerConfig('development'); + const logMethod = config.pinoHttp?.hooks?.logMethod; + const sink = vi.fn(); + expect(logMethod).toBeDefined(); + + logMethod?.call({}, [{ context: 'InstanceLoader', msg: 'skip me' }], sink); + expect(sink).not.toHaveBeenCalled(); + + logMethod?.call({}, [{ context: 'AppBootstrap', msg: 'keep me' }], sink); + expect(sink).toHaveBeenCalledTimes(1); + }); + + it('serializes request and response objects with minimal fields', async () => { + const config = await loadLoggerConfig('development'); + const serializers = config.pinoHttp?.serializers; + expect(serializers?.req?.({ id: 'req-1', method: 'GET', url: '/api/x' } as never)).toEqual({ + id: 'req-1', + method: 'GET', + url: '/api/x', + }); + expect(serializers?.res?.({ statusCode: 204 } as never)).toEqual({ statusCode: 204 }); + }); +}); diff --git a/server/src/common/utils/user-avatar-url.test.ts b/server/src/common/utils/user-avatar-url.test.ts new file mode 100644 index 000000000..9c34a754f --- /dev/null +++ b/server/src/common/utils/user-avatar-url.test.ts @@ -0,0 +1,44 @@ +import { resolveUserAvatarUrl } from './user-avatar-url'; + +describe('resolveUserAvatarUrl', () => { + it('builds versioned API URL for uploaded avatars', () => { + expect( + resolveUserAvatarUrl({ + id: 42, + avatarUrl: 'https://cdn.example/avatar.png', + avatarSource: 'uploaded', + avatarVersion: 7, + }), + ).toBe('/api/v1/users/42/avatar?v=7'); + }); + + it('clamps invalid or negative avatar version to zero for uploaded avatars', () => { + expect( + resolveUserAvatarUrl({ + id: 42, + avatarUrl: null, + avatarSource: 'uploaded', + avatarVersion: -10, + }), + ).toBe('/api/v1/users/42/avatar?v=0'); + + expect( + resolveUserAvatarUrl({ + id: 42, + avatarUrl: null, + avatarSource: 'uploaded', + avatarVersion: Number.NaN, + }), + ).toBe('/api/v1/users/42/avatar?v=0'); + }); + + it('returns stored avatarUrl when avatar source is not uploaded', () => { + expect( + resolveUserAvatarUrl({ + id: 7, + avatarUrl: 'https://cdn.example/external.png', + avatarSource: 'external', + }), + ).toBe('https://cdn.example/external.png'); + }); +}); diff --git a/server/src/modules/annotation/annotation.controller.test.ts b/server/src/modules/annotation/annotation.controller.test.ts new file mode 100644 index 000000000..c28726901 --- /dev/null +++ b/server/src/modules/annotation/annotation.controller.test.ts @@ -0,0 +1,81 @@ +import type { RequestUser } from '../../common/types/request-user'; +import { AnnotationController } from './annotation.controller'; + +function makeUser(overrides?: Partial): RequestUser { + return { + id: 7, + username: 'reader', + name: 'Reader', + email: null, + active: true, + isSuperuser: false, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + ...overrides, + }; +} + +function makeController() { + const annotationService = { + getAnnotations: vi.fn(), + createAnnotation: vi.fn(), + updateAnnotation: vi.fn(), + deleteAnnotation: vi.fn(), + }; + + return { + controller: new AnnotationController(annotationService as never), + annotationService, + }; +} + +describe('AnnotationController', () => { + it('delegates getAnnotations with bookId and current user', async () => { + const { controller, annotationService } = makeController(); + const expected = [{ id: 1 }]; + annotationService.getAnnotations.mockResolvedValue(expected); + const user = makeUser(); + + const result = await controller.getAnnotations(15, user); + + expect(annotationService.getAnnotations).toHaveBeenCalledWith(15, user); + expect(result).toEqual(expected); + }); + + it('delegates createAnnotation with dto payload', async () => { + const { controller, annotationService } = makeController(); + const dto = { cfi: 'epubcfi(/6/4)', text: 'selected text', note: null }; + const user = makeUser(); + annotationService.createAnnotation.mockResolvedValue({ id: 9 }); + + const result = await controller.createAnnotation(22, dto, user); + + expect(annotationService.createAnnotation).toHaveBeenCalledWith(22, user, dto); + expect(result).toEqual({ id: 9 }); + }); + + it('delegates updateAnnotation with route ids and patch dto', async () => { + const { controller, annotationService } = makeController(); + const dto = { color: '#FACC15', style: 'underline' }; + const user = makeUser(); + annotationService.updateAnnotation.mockResolvedValue({ id: 9, color: '#FACC15' }); + + const result = await controller.updateAnnotation(22, 9, dto, user); + + expect(annotationService.updateAnnotation).toHaveBeenCalledWith(22, 9, user, dto); + expect(result).toEqual({ id: 9, color: '#FACC15' }); + }); + + it('awaits deleteAnnotation and returns no content payload', async () => { + const { controller, annotationService } = makeController(); + const user = makeUser(); + annotationService.deleteAnnotation.mockResolvedValue(undefined); + + await expect(controller.deleteAnnotation(22, 9, user)).resolves.toBeUndefined(); + expect(annotationService.deleteAnnotation).toHaveBeenCalledWith(22, 9, user); + }); +}); diff --git a/server/src/modules/annotation/annotation.module.test.ts b/server/src/modules/annotation/annotation.module.test.ts new file mode 100644 index 000000000..3c44778e0 --- /dev/null +++ b/server/src/modules/annotation/annotation.module.test.ts @@ -0,0 +1,21 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { BookModule } from '../book/book.module'; +import { AnnotationController } from './annotation.controller'; +import { AnnotationModule } from './annotation.module'; +import { AnnotationRepository } from './annotation.repository'; +import { AnnotationService } from './annotation.service'; + +describe('AnnotationModule', () => { + it('registers expected imports, controller, and providers', () => { + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AnnotationModule); + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AnnotationModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AnnotationModule); + + expect(imports).toEqual(expect.arrayContaining([BookModule])); + expect(controllers).toEqual([AnnotationController]); + expect(providers).toEqual(expect.arrayContaining([AnnotationService, AnnotationRepository])); + }); +}); diff --git a/server/src/modules/annotation/dto/annotation-dto-validation.test.ts b/server/src/modules/annotation/dto/annotation-dto-validation.test.ts new file mode 100644 index 000000000..edc0221e0 --- /dev/null +++ b/server/src/modules/annotation/dto/annotation-dto-validation.test.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateAnnotationDto } from './create-annotation.dto'; +import { UpdateAnnotationDto } from './update-annotation.dto'; + +async function errorsFor(cls: new () => T, value: Record) { + const dto = plainToInstance(cls, value); + return validate(dto); +} + +describe('Annotation DTO validation', () => { + it('accepts create payload with optional fields and valid style', async () => { + const errors = await errorsFor(CreateAnnotationDto, { + cfi: 'epubcfi(/6/4!/4/2/1:0)', + text: 'Selected text', + color: '#FACC15', + style: 'underline', + note: 'reader note', + chapterTitle: 'Chapter 1', + }); + + expect(errors).toHaveLength(0); + }); + + it('rejects invalid create payload boundaries', async () => { + expect((await errorsFor(CreateAnnotationDto, { cfi: '', text: 'ok' })).length).toBeGreaterThan(0); + expect((await errorsFor(CreateAnnotationDto, { cfi: 'x', text: '' })).length).toBeGreaterThan(0); + expect((await errorsFor(CreateAnnotationDto, { cfi: 'x', text: 'ok', color: 'x'.repeat(21) })).length).toBeGreaterThan(0); + expect((await errorsFor(CreateAnnotationDto, { cfi: 'x', text: 'ok', style: 'invalid' })).length).toBeGreaterThan(0); + expect((await errorsFor(CreateAnnotationDto, { cfi: 'x', text: 'ok', chapterTitle: 'x'.repeat(501) })).length).toBeGreaterThan(0); + }); + + it('accepts update payload with nullable note and optional style', async () => { + const nullNoteErrors = await errorsFor(UpdateAnnotationDto, { note: null, style: 'highlight' }); + const normalErrors = await errorsFor(UpdateAnnotationDto, { note: 'updated', color: '#38BDF8' }); + + expect(nullNoteErrors).toHaveLength(0); + expect(normalErrors).toHaveLength(0); + }); + + it('rejects invalid update payload types', async () => { + expect((await errorsFor(UpdateAnnotationDto, { note: 123 })).length).toBeGreaterThan(0); + expect((await errorsFor(UpdateAnnotationDto, { color: 'x'.repeat(21) })).length).toBeGreaterThan(0); + expect((await errorsFor(UpdateAnnotationDto, { style: 'invalid' })).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/app-settings/dto/app-settings-dto-validation.test.ts b/server/src/modules/app-settings/dto/app-settings-dto-validation.test.ts new file mode 100644 index 000000000..1dcaf7cfa --- /dev/null +++ b/server/src/modules/app-settings/dto/app-settings-dto-validation.test.ts @@ -0,0 +1,68 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { UpdateAppSettingDto } from './update-app-setting.dto'; +import { UpdateFilePatternDto } from './update-file-pattern.dto'; +import { UpdateOidcConfigDto } from './update-oidc-config.dto'; + +async function errorsFor(cls: new () => T, input: Record) { + return validate(plainToInstance(cls, input)); +} + +describe('App settings DTO validation', () => { + it('validates update-app-setting payload value as required non-empty string', async () => { + expect((await errorsFor(UpdateAppSettingDto, { value: 'enabled' })).length).toBe(0); + expect((await errorsFor(UpdateAppSettingDto, { value: '' })).length).toBeGreaterThan(0); + expect((await errorsFor(UpdateAppSettingDto, { value: 123 })).length).toBeGreaterThan(0); + }); + + it('accepts valid file patterns and rejects invalid characters', async () => { + expect((await errorsFor(UpdateFilePatternDto, { pattern: '{author}/{title}' })).length).toBe(0); + expect((await errorsFor(UpdateFilePatternDto, { pattern: '@@@' })).length).toBeGreaterThan(0); + expect((await errorsFor(UpdateFilePatternDto, { pattern: 'x'.repeat(501) })).length).toBeGreaterThan(0); + }); + + it('validates nested OIDC claimMapping and autoProvision fields', async () => { + expect( + ( + await errorsFor(UpdateOidcConfigDto, { + enabled: true, + providerName: 'Keycloak', + issuerUri: 'https://id.example.com/realms/main', + clientId: 'projectx', + clientSecret: 'secret', + scopes: 'openid profile email', + claimMapping: { + username: 'preferred_username', + name: 'name', + email: 'email', + groups: 'groups', + }, + autoProvision: { + enabled: true, + allowLocalLinking: false, + defaultPermissionNames: ['library_download'], + }, + }) + ).length, + ).toBe(0); + + const invalid = await errorsFor(UpdateOidcConfigDto, { + enabled: 'true', + claimMapping: { + username: 'preferred_username', + name: 123, + }, + autoProvision: { + enabled: true, + allowLocalLinking: 'yes', + defaultPermissionNames: [1, 2], + }, + }); + + expect(invalid.length).toBeGreaterThan(0); + expect(invalid.map((err) => err.property)).toEqual(expect.arrayContaining(['enabled', 'claimMapping', 'autoProvision'])); + }); +}); diff --git a/server/src/modules/audit/audit-cleanup.job.test.ts b/server/src/modules/audit/audit-cleanup.job.test.ts new file mode 100644 index 000000000..cdb91aff6 --- /dev/null +++ b/server/src/modules/audit/audit-cleanup.job.test.ts @@ -0,0 +1,24 @@ +import { AuditCleanupJob } from './audit-cleanup.job'; + +describe('AuditCleanupJob', () => { + it('runs retention cleanup through audit service', async () => { + const auditService = { + runRetentionCleanup: vi.fn().mockResolvedValue(undefined), + }; + const job = new AuditCleanupJob(auditService as never); + + await job.runCleanup(); + + expect(auditService.runRetentionCleanup).toHaveBeenCalledTimes(1); + }); + + it('swallows thrown errors from service cleanup', async () => { + const auditService = { + runRetentionCleanup: vi.fn().mockRejectedValue(new Error('cleanup failed')), + }; + const job = new AuditCleanupJob(auditService as never); + + await expect(job.runCleanup()).resolves.toBeUndefined(); + expect(auditService.runRetentionCleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/modules/audit/audit.controller.test.ts b/server/src/modules/audit/audit.controller.test.ts new file mode 100644 index 000000000..cef2308f6 --- /dev/null +++ b/server/src/modules/audit/audit.controller.test.ts @@ -0,0 +1,57 @@ +import { AuditAction, AuditResource } from '@projectx/types'; + +import { AuditController } from './audit.controller'; + +describe('AuditController', () => { + it('maps query DTO values and converts date strings to Date objects', async () => { + const auditService = { + getAuditLogs: vi.fn().mockResolvedValue({ data: [], total: 0 }), + }; + const controller = new AuditController(auditService as never); + + const action = Object.values(AuditAction)[0]; + const resource = Object.values(AuditResource)[0]; + + await controller.getAuditLogs({ + userId: 9, + action, + resource, + dateFrom: '2026-02-01T00:00:00.000Z', + dateTo: '2026-02-15T12:34:56.000Z', + page: 2, + pageSize: 25, + }); + + expect(auditService.getAuditLogs).toHaveBeenCalledWith({ + userId: 9, + action, + resource, + dateFrom: new Date('2026-02-01T00:00:00.000Z'), + dateTo: new Date('2026-02-15T12:34:56.000Z'), + page: 2, + pageSize: 25, + }); + }); + + it('passes undefined filters when optional query values are omitted', async () => { + const auditService = { + getAuditLogs: vi.fn().mockResolvedValue({ data: [], total: 0 }), + }; + const controller = new AuditController(auditService as never); + + await controller.getAuditLogs({ + page: 1, + pageSize: 50, + }); + + expect(auditService.getAuditLogs).toHaveBeenCalledWith({ + userId: undefined, + action: undefined, + resource: undefined, + dateFrom: undefined, + dateTo: undefined, + page: 1, + pageSize: 50, + }); + }); +}); diff --git a/server/src/modules/audit/audit.module.test.ts b/server/src/modules/audit/audit.module.test.ts new file mode 100644 index 000000000..115add15d --- /dev/null +++ b/server/src/modules/audit/audit.module.test.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { AppSettingsModule } from '../app-settings/app-settings.module'; +import { AuditCleanupJob } from './audit-cleanup.job'; +import { AuditController } from './audit.controller'; +import { AuditEventsService } from './audit-events.service'; +import { AuditModule } from './audit.module'; +import { AuditRepository } from './audit.repository'; +import { AuditService } from './audit.service'; + +describe('AuditModule', () => { + it('registers expected module metadata', () => { + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AuditModule); + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AuditModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AuditModule); + const exports = Reflect.getMetadata(MODULE_METADATA.EXPORTS, AuditModule); + + expect(imports).toEqual(expect.arrayContaining([AppSettingsModule])); + expect(controllers).toEqual([AuditController]); + expect(providers).toEqual(expect.arrayContaining([AuditEventsService, AuditRepository, AuditService, AuditCleanupJob])); + expect(exports).toEqual(expect.arrayContaining([AuditEventsService, AuditService])); + }); +}); diff --git a/server/src/modules/audit/audit.repository.test.ts b/server/src/modules/audit/audit.repository.test.ts new file mode 100644 index 000000000..446830b88 --- /dev/null +++ b/server/src/modules/audit/audit.repository.test.ts @@ -0,0 +1,106 @@ +import { AuditAction, AuditResource } from '@projectx/types'; + +import { AuditRepository } from './audit.repository'; + +function makeRepository(options?: { data?: unknown[]; total?: number }) { + const data = options?.data ?? []; + const total = options?.total ?? data.length; + + const dataLimit = { offset: vi.fn().mockResolvedValue(data) }; + const dataOrder = { limit: vi.fn().mockReturnValue(dataLimit) }; + const dataWhere = { orderBy: vi.fn().mockReturnValue(dataOrder) }; + const dataFrom = { where: vi.fn().mockReturnValue(dataWhere) }; + const dataSelect = { from: vi.fn().mockReturnValue(dataFrom) }; + + const countFrom = { where: vi.fn().mockResolvedValue([{ total }]) }; + const countSelect = { from: vi.fn().mockReturnValue(countFrom) }; + + const insertValues = vi.fn().mockResolvedValue(undefined); + const insertBuilder = { values: insertValues }; + + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteBuilder = { where: deleteWhere }; + + const db = { + select: vi.fn((selection?: unknown) => (selection ? countSelect : dataSelect)), + insert: vi.fn().mockReturnValue(insertBuilder), + delete: vi.fn().mockReturnValue(deleteBuilder), + _dataFrom: dataFrom, + _dataWhere: dataWhere, + _dataOrder: dataOrder, + _dataLimit: dataLimit, + _countFrom: countFrom, + _insertValues: insertValues, + _deleteWhere: deleteWhere, + }; + + return { + repo: new AuditRepository(db as never), + db, + }; +} + +describe('AuditRepository', () => { + it('insert stores audit records', async () => { + const { repo, db } = makeRepository(); + + await repo.insert({ + userId: 3, + actorUsername: 'admin', + action: AuditAction.BookMetadataUpdate, + resource: AuditResource.Book, + resourceId: 22, + description: 'Updated metadata', + ip: '127.0.0.1', + meta: { source: 'test' }, + } as never); + + expect(db.insert).toHaveBeenCalledTimes(1); + expect(db._insertValues).toHaveBeenCalledTimes(1); + }); + + it('findAll applies pagination and filter conditions', async () => { + const rows = [{ id: 1 }, { id: 2 }]; + const { repo, db } = makeRepository({ data: rows, total: 7 }); + + const result = await repo.findAll({ + userId: 9, + action: AuditAction.LibraryUpdate, + resource: AuditResource.Library, + dateFrom: new Date('2026-01-01T00:00:00.000Z'), + dateTo: new Date('2026-01-31T23:59:59.000Z'), + page: 3, + pageSize: 25, + }); + + expect(db._dataFrom.where).toHaveBeenCalledWith(expect.anything()); + expect(db._dataWhere.orderBy).toHaveBeenCalled(); + expect(db._dataOrder.limit).toHaveBeenCalledWith(25); + expect(db._dataLimit.offset).toHaveBeenCalledWith(50); + expect(db._countFrom.where).toHaveBeenCalledWith(expect.anything()); + expect(result).toEqual({ data: rows, total: 7 }); + }); + + it('findAll keeps where undefined when no filters are supplied', async () => { + const { repo, db } = makeRepository({ data: [], total: 0 }); + + await repo.findAll({ + page: 1, + pageSize: 50, + }); + + expect(db._dataFrom.where).toHaveBeenCalledWith(undefined); + expect(db._countFrom.where).toHaveBeenCalledWith(undefined); + expect(db._dataLimit.offset).toHaveBeenCalledWith(0); + }); + + it('deleteOlderThan removes entries before cutoff date', async () => { + const { repo, db } = makeRepository(); + const cutoff = new Date('2025-12-31T00:00:00.000Z'); + + await repo.deleteOlderThan(cutoff); + + expect(db.delete).toHaveBeenCalledTimes(1); + expect(db._deleteWhere).toHaveBeenCalledWith(expect.anything()); + }); +}); diff --git a/server/src/modules/audit/audit.service.test.ts b/server/src/modules/audit/audit.service.test.ts new file mode 100644 index 000000000..5e94f985b --- /dev/null +++ b/server/src/modules/audit/audit.service.test.ts @@ -0,0 +1,198 @@ +import { APP_SETTING_KEYS, DEFAULT_AUDIT_RETENTION_DAYS } from '../../common/constants/app-settings.constants'; +import { AuditAction } from '@projectx/types'; +import { AUDIT_EVENT, AuditEventsService } from './audit-events.service'; +import { AuditService } from './audit.service'; + +function flushMicrotasks() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function makePayload(overrides?: Record) { + return { + userId: 12, + actorUsername: 'admin', + action: AuditAction.BookMetadataUpdate, + resource: undefined, + resourceId: undefined, + description: 'updated book metadata', + ip: undefined, + meta: undefined, + ...overrides, + }; +} + +function makeService() { + const auditEvents = new AuditEventsService(); + const auditRepository = { + insert: vi.fn().mockResolvedValue(undefined), + findAll: vi.fn(), + deleteOlderThan: vi.fn().mockResolvedValue(undefined), + }; + const appSettings = { + getValue: vi.fn(), + }; + + const service = new AuditService(auditEvents as never, auditRepository as never, appSettings as never); + return { service, auditEvents, auditRepository, appSettings }; +} + +describe('AuditService', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('registers and unregisters the audit event listener', async () => { + const { service, auditEvents } = makeService(); + const onSpy = vi.spyOn(auditEvents, 'on'); + const offSpy = vi.spyOn(auditEvents, 'off'); + + service.onModuleInit(); + expect(onSpy).toHaveBeenCalledWith(AUDIT_EVENT, expect.any(Function)); + + await service.onModuleDestroy(); + expect(offSpy).toHaveBeenCalledWith(AUDIT_EVENT, expect.any(Function)); + }); + + it('writes normalized audit records when events are emitted', async () => { + const { service, auditEvents, auditRepository } = makeService(); + service.onModuleInit(); + + auditEvents.emit(AUDIT_EVENT, makePayload()); + await flushMicrotasks(); + + expect(auditRepository.insert).toHaveBeenCalledWith({ + userId: 12, + actorUsername: 'admin', + action: AuditAction.BookMetadataUpdate, + resource: null, + resourceId: null, + description: 'updated book metadata', + ip: null, + meta: null, + }); + + await service.onModuleDestroy(); + }); + + it('drops writes when queue reaches max concurrent capacity', async () => { + const { service, auditEvents, auditRepository } = makeService(); + const allPending = Array.from({ length: 20 }, () => deferred()); + const queue = [...allPending]; + auditRepository.insert.mockImplementation(() => queue.shift()!.promise); + const warnSpy = vi.spyOn((service as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation(() => undefined); + + service.onModuleInit(); + + for (let i = 0; i < 21; i += 1) { + auditEvents.emit(AUDIT_EVENT, makePayload({ description: `event-${i}` })); + } + + expect(auditRepository.insert).toHaveBeenCalledTimes(20); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('write queue full')); + + for (const item of allPending) { + item.resolve(); + } + await service.onModuleDestroy(); + }); + + it('logs insert failures without throwing to callers', async () => { + const { service, auditEvents, auditRepository } = makeService(); + auditRepository.insert.mockRejectedValueOnce(new Error('database unavailable')); + const errorSpy = vi.spyOn((service as { logger: { error: (message: string) => void } }).logger, 'error').mockImplementation(() => undefined); + + service.onModuleInit(); + auditEvents.emit(AUDIT_EVENT, makePayload()); + await flushMicrotasks(); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('database unavailable')); + await service.onModuleDestroy(); + }); + + it('waits for pending writes during module destroy', async () => { + const { service, auditEvents, auditRepository } = makeService(); + const pendingWrite = deferred(); + auditRepository.insert.mockImplementationOnce(() => pendingWrite.promise); + service.onModuleInit(); + auditEvents.emit(AUDIT_EVENT, makePayload()); + + let completed = false; + const destroyPromise = service.onModuleDestroy().then(() => { + completed = true; + }); + + await flushMicrotasks(); + expect(completed).toBe(false); + + pendingWrite.resolve(); + await destroyPromise; + expect(completed).toBe(true); + }); + + it('delegates getAuditLogs query to repository', async () => { + const { service, auditRepository } = makeService(); + const query = { page: 1, pageSize: 50 }; + auditRepository.findAll.mockResolvedValue({ data: [{ id: 1 }], total: 1 }); + + const result = await service.getAuditLogs(query); + + expect(auditRepository.findAll).toHaveBeenCalledWith(query); + expect(result).toEqual({ data: [{ id: 1 }], total: 1 }); + }); + + it('returns default retention days when setting is missing or invalid', async () => { + const { service, appSettings } = makeService(); + appSettings.getValue.mockResolvedValueOnce(null).mockResolvedValueOnce('0').mockResolvedValueOnce('not-a-number'); + + await expect(service.getRetentionDays()).resolves.toBe(DEFAULT_AUDIT_RETENTION_DAYS); + await expect(service.getRetentionDays()).resolves.toBe(DEFAULT_AUDIT_RETENTION_DAYS); + await expect(service.getRetentionDays()).resolves.toBe(DEFAULT_AUDIT_RETENTION_DAYS); + }); + + it('parses positive configured retention days', async () => { + const { service, appSettings } = makeService(); + appSettings.getValue.mockResolvedValue('45'); + + await expect(service.getRetentionDays()).resolves.toBe(45); + expect(appSettings.getValue).toHaveBeenCalledWith(APP_SETTING_KEYS.AUDIT_RETENTION_DAYS); + }); + + it('falls back to default retention days when settings lookup throws', async () => { + const { service, appSettings } = makeService(); + appSettings.getValue.mockRejectedValue(new Error('settings read failed')); + const errorSpy = vi.spyOn((service as { logger: { error: (message: string) => void } }).logger, 'error').mockImplementation(() => undefined); + + await expect(service.getRetentionDays()).resolves.toBe(DEFAULT_AUDIT_RETENTION_DAYS); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('settings read failed')); + }); + + it('runRetentionCleanup deletes old records based on configured retention window', async () => { + const { service, appSettings, auditRepository } = makeService(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-21T00:00:00.000Z')); + appSettings.getValue.mockResolvedValue('10'); + + await service.runRetentionCleanup(); + + expect(auditRepository.deleteOlderThan).toHaveBeenCalledTimes(1); + const cutoff = auditRepository.deleteOlderThan.mock.calls[0][0] as Date; + expect(cutoff.toISOString()).toBe('2026-04-11T00:00:00.000Z'); + }); + + it('runRetentionCleanup rethrows repository failures', async () => { + const { service, appSettings, auditRepository } = makeService(); + appSettings.getValue.mockResolvedValue('14'); + auditRepository.deleteOlderThan.mockRejectedValue(new Error('cleanup failed')); + + await expect(service.runRetentionCleanup()).rejects.toThrow('cleanup failed'); + }); +}); diff --git a/server/src/modules/audit/dto/audit-log-query.dto.test.ts b/server/src/modules/audit/dto/audit-log-query.dto.test.ts new file mode 100644 index 000000000..7997e2b4e --- /dev/null +++ b/server/src/modules/audit/dto/audit-log-query.dto.test.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { AuditAction, AuditResource } from '@projectx/types'; + +import { AuditLogQueryDto } from './audit-log-query.dto'; + +describe('AuditLogQueryDto', () => { + it('transforms numeric query strings and validates enums/date strings', async () => { + const dto = plainToInstance(AuditLogQueryDto, { + userId: '11', + action: Object.values(AuditAction)[0], + resource: Object.values(AuditResource)[0], + dateFrom: '2026-01-01T00:00:00.000Z', + dateTo: '2026-01-31T23:59:59.000Z', + page: '2', + pageSize: '75', + }); + + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.userId).toBe(11); + expect(dto.page).toBe(2); + expect(dto.pageSize).toBe(75); + }); + + it('keeps default pagination values when omitted', async () => { + const dto = plainToInstance(AuditLogQueryDto, {}); + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.page).toBe(1); + expect(dto.pageSize).toBe(50); + }); + + it('rejects invalid bounds and malformed values', async () => { + expect((await validate(plainToInstance(AuditLogQueryDto, { userId: '0' }))).length).toBeGreaterThan(0); + expect((await validate(plainToInstance(AuditLogQueryDto, { page: '0' }))).length).toBeGreaterThan(0); + expect((await validate(plainToInstance(AuditLogQueryDto, { pageSize: '500' }))).length).toBeGreaterThan(0); + expect((await validate(plainToInstance(AuditLogQueryDto, { action: 'not.valid' }))).length).toBeGreaterThan(0); + expect((await validate(plainToInstance(AuditLogQueryDto, { dateFrom: 'not-a-date' }))).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/authors/author-enrichment-config.service.test.ts b/server/src/modules/authors/author-enrichment-config.service.test.ts new file mode 100644 index 000000000..60fc356dd --- /dev/null +++ b/server/src/modules/authors/author-enrichment-config.service.test.ts @@ -0,0 +1,104 @@ +import type { AuthorAutoEnrichmentConfig } from '@projectx/types'; + +import { DEFAULT_AUTHOR_ENRICHMENT_CONFIG, AuthorEnrichmentConfigService } from './author-enrichment-config.service'; + +function makeService() { + const findFirst = vi.fn(); + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + + const db = { + query: { + appSettings: { + findFirst, + }, + }, + insert, + }; + + return { + service: new AuthorEnrichmentConfigService(db as never), + db, + insert, + findFirst, + values, + onConflictDoUpdate, + }; +} + +describe('AuthorEnrichmentConfigService', () => { + it('returns deep-cloned defaults when no config is stored', async () => { + const { service, findFirst } = makeService(); + findFirst.mockResolvedValue(null); + + const result = await service.getConfig(); + + expect(result).toEqual(DEFAULT_AUTHOR_ENRICHMENT_CONFIG); + expect(result).not.toBe(DEFAULT_AUTHOR_ENRICHMENT_CONFIG); + expect(result.conditions).not.toBe(DEFAULT_AUTHOR_ENRICHMENT_CONFIG.conditions); + }); + + it('merges partial stored config with defaults', async () => { + const { service, findFirst } = makeService(); + findFirst.mockResolvedValue({ + value: JSON.stringify({ + enabled: true, + conditions: { + missingBio: false, + }, + }), + }); + + const result = await service.getConfig(); + + expect(result.enabled).toBe(true); + expect(result.conditions.missingBio).toBe(false); + expect(result.conditions.missingPhoto).toBe(DEFAULT_AUTHOR_ENRICHMENT_CONFIG.conditions.missingPhoto); + }); + + it('falls back to defaults when stored config JSON is malformed', async () => { + const { service, findFirst } = makeService(); + findFirst.mockResolvedValue({ value: '{invalid-json' }); + + await expect(service.getConfig()).resolves.toEqual(DEFAULT_AUTHOR_ENRICHMENT_CONFIG); + }); + + it('persists config through upsert', async () => { + const { service, insert, values, onConflictDoUpdate } = makeService(); + const config: AuthorAutoEnrichmentConfig = { + enabled: true, + triggerOnImport: false, + writeMode: 'always_refetch', + conditions: { + neverEnriched: true, + missingBio: true, + missingPhoto: false, + }, + }; + + await service.setConfig(config); + + expect(insert).toHaveBeenCalledTimes(1); + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + value: JSON.stringify(config), + }), + ); + expect(onConflictDoUpdate).toHaveBeenCalledTimes(1); + }); + + it('reads and writes paused flag as string booleans', async () => { + const { service, findFirst, values } = makeService(); + findFirst.mockResolvedValueOnce({ value: 'true' }).mockResolvedValueOnce({ value: 'false' }); + + await expect(service.isPaused()).resolves.toBe(true); + await expect(service.isPaused()).resolves.toBe(false); + + await service.setPaused(true); + await service.setPaused(false); + + expect(values).toHaveBeenCalledWith(expect.objectContaining({ value: 'true' })); + expect(values).toHaveBeenCalledWith(expect.objectContaining({ value: 'false' })); + }); +}); diff --git a/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts b/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts index 8713f2213..2be898740 100644 --- a/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts +++ b/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts @@ -10,6 +10,9 @@ describe('AuthorEnrichmentOrchestratorService', () => { enqueueAllLinkedAuthors: vi.fn(), enqueueEligibleLinkedAuthors: vi.fn(), filterEligibleAuthorIds: vi.fn(), + cancelPending: vi.fn(), + requeueFailed: vi.fn(), + getFailedItems: vi.fn(), getStatusSummary: vi.fn(), recoverStuckProcessing: vi.fn(), resetAllProcessingOnBoot: vi.fn(), @@ -53,6 +56,9 @@ describe('AuthorEnrichmentOrchestratorService', () => { queueRepo.enqueueAllLinkedAuthors.mockResolvedValue(0); queueRepo.enqueueEligibleLinkedAuthors.mockResolvedValue(0); queueRepo.filterEligibleAuthorIds.mockImplementation((ids: number[]) => Promise.resolve(ids)); + queueRepo.cancelPending.mockResolvedValue(0); + queueRepo.requeueFailed.mockResolvedValue(0); + queueRepo.getFailedItems.mockResolvedValue({ items: [], total: 0 }); queueRepo.resetAllProcessingOnBoot.mockResolvedValue(0); queueRepo.recoverStuckProcessing.mockResolvedValue(0); queueRepo.getStatusSummary.mockResolvedValue({ @@ -179,4 +185,68 @@ describe('AuthorEnrichmentOrchestratorService', () => { expect(queueRepo.resetAllProcessingOnBoot).toHaveBeenCalled(); expect(enrichmentConfig.isPaused).toHaveBeenCalled(); }); + + it('scheduleMany skips queueing when orchestrator is paused and request is not manual override', async () => { + (service as any).paused = true; + + const queued = await service.scheduleMany([1, 2], AUTHOR_ENRICHMENT_REASONS.AUTHOR_RENAME); + + expect(queued).toBe(0); + expect(queueRepo.upsertSchedule).not.toHaveBeenCalled(); + }); + + it('metadata_replace queueing stays silent in session status', async () => { + queueRepo.upsertSchedule.mockResolvedValue(2); + + const queued = await service.scheduleMany([1, 2], AUTHOR_ENRICHMENT_REASONS.METADATA_REPLACE, { ignoreEnabled: true }); + + expect(queued).toBe(2); + expect(session.getSnapshot().sessionTotal).toBe(0); + expect(gateway.emitStatus).not.toHaveBeenCalled(); + }); + + it('manual queueing increments session total and emits status', async () => { + queueRepo.upsertSchedule.mockResolvedValue(3); + + const queued = await service.scheduleMany([1, 2, 3], AUTHOR_ENRICHMENT_REASONS.AUTHOR_RENAME, { ignoreEnabled: true }); + + expect(queued).toBe(3); + expect(session.getSnapshot().sessionTotal).toBe(3); + expect(gateway.emitStatus).toHaveBeenCalled(); + }); + + it('backfillLinkedAuthors unpauses orchestrator when work is queued', async () => { + (service as any).paused = true; + queueRepo.enqueueEligibleLinkedAuthors.mockResolvedValue(5); + + const queued = await service.backfillLinkedAuthors(); + + expect(queued).toBe(5); + expect(enrichmentConfig.setPaused).toHaveBeenCalledWith(false); + expect(gateway.emitStatus).toHaveBeenCalled(); + }); + + it('cancelPending pauses and resets session counters', async () => { + session.sessionTotal = 10; + session.sessionDone = 4; + session.currentItemName = 'Someone'; + + await service.cancelPending(); + + expect(queueRepo.cancelPending).toHaveBeenCalled(); + expect(enrichmentConfig.setPaused).toHaveBeenCalledWith(true); + expect(session.getSnapshot()).toEqual({ + sessionTotal: 0, + sessionDone: 0, + currentItemName: null, + }); + }); + + it('requeueFailed increments session total when rows are requeued', async () => { + queueRepo.requeueFailed.mockResolvedValue(4); + + await expect(service.requeueFailed()).resolves.toBe(4); + expect(session.getSnapshot().sessionTotal).toBe(4); + expect(gateway.emitStatus).toHaveBeenCalled(); + }); }); diff --git a/server/src/modules/authors/author-enrichment-reasons.test.ts b/server/src/modules/authors/author-enrichment-reasons.test.ts new file mode 100644 index 000000000..81b63e404 --- /dev/null +++ b/server/src/modules/authors/author-enrichment-reasons.test.ts @@ -0,0 +1,14 @@ +import { AUTHOR_ENRICHMENT_REASONS } from './author-enrichment-reasons'; + +describe('AUTHOR_ENRICHMENT_REASONS', () => { + it('exposes stable reason keys used by queue scheduling flows', () => { + expect(AUTHOR_ENRICHMENT_REASONS).toEqual({ + UNKNOWN: 'unknown', + METADATA_REPLACE: 'metadata_replace', + MANUAL_BACKFILL: 'manual_backfill', + MANUAL_BACKFILL_ALL: 'manual_backfill_all', + AUTHOR_RENAME: 'author_rename', + AUTHOR_MERGE_TARGET: 'author_merge_target', + }); + }); +}); diff --git a/server/src/modules/authors/author-enrichment-session.service.test.ts b/server/src/modules/authors/author-enrichment-session.service.test.ts new file mode 100644 index 000000000..16ebe0f5e --- /dev/null +++ b/server/src/modules/authors/author-enrichment-session.service.test.ts @@ -0,0 +1,32 @@ +import { AuthorEnrichmentSessionService } from './author-enrichment-session.service'; + +describe('AuthorEnrichmentSessionService', () => { + it('returns live snapshot values', () => { + const session = new AuthorEnrichmentSessionService(); + + session.sessionTotal = 5; + session.sessionDone = 2; + session.currentItemName = 'Author Name'; + + expect(session.getSnapshot()).toEqual({ + sessionTotal: 5, + sessionDone: 2, + currentItemName: 'Author Name', + }); + }); + + it('resets all session counters and current item', () => { + const session = new AuthorEnrichmentSessionService(); + session.sessionTotal = 3; + session.sessionDone = 1; + session.currentItemName = 'Working'; + + session.reset(); + + expect(session.getSnapshot()).toEqual({ + sessionTotal: 0, + sessionDone: 0, + currentItemName: null, + }); + }); +}); diff --git a/server/src/modules/authors/author-enrichment.gateway.test.ts b/server/src/modules/authors/author-enrichment.gateway.test.ts index d49b52d96..a042f26b3 100644 --- a/server/src/modules/authors/author-enrichment.gateway.test.ts +++ b/server/src/modules/authors/author-enrichment.gateway.test.ts @@ -1,19 +1,30 @@ import { AuthorEnrichmentGateway } from './author-enrichment.gateway'; +import { AUTHOR_ENRICHMENT_STATUS_EVENT } from './author-enrichment.gateway'; +import { Permission } from '@projectx/types'; function makeGateway() { - return new AuthorEnrichmentGateway( - { verify: vi.fn() } as any, - { validateUser: vi.fn() } as any, - { getStatusSummary: vi.fn() } as any, - { isPaused: vi.fn() } as any, - { getSnapshot: vi.fn() } as any, - { get: vi.fn().mockReturnValue('http://localhost:5173') } as any, + const jwtService = { verify: vi.fn() }; + const authService = { validateUser: vi.fn() }; + const queueRepo = { getStatusSummary: vi.fn() }; + const enrichmentConfig = { isPaused: vi.fn() }; + const session = { getSnapshot: vi.fn() }; + const configService = { get: vi.fn().mockReturnValue('http://localhost:5173') }; + + const gateway = new AuthorEnrichmentGateway( + jwtService as any, + authService as any, + queueRepo as any, + enrichmentConfig as any, + session as any, + configService as any, ); + + return { gateway, jwtService, authService, queueRepo, enrichmentConfig, session }; } describe('AuthorEnrichmentGateway', () => { it('emitStatus broadcasts to connected sockets', () => { - const gateway = makeGateway(); + const { gateway } = makeGateway(); const emit = vi.fn(); gateway['server'] = { emit } as any; @@ -37,7 +48,7 @@ describe('AuthorEnrichmentGateway', () => { }); it('emitStatus does not throw when server is undefined', () => { - const gateway = makeGateway(); + const { gateway } = makeGateway(); gateway['server'] = undefined as any; expect(() => @@ -51,4 +62,87 @@ describe('AuthorEnrichmentGateway', () => { }), ).not.toThrow(); }); + + it('afterInit injects runtime cors origin and credentials on socket engine', () => { + const { gateway } = makeGateway(); + const server = { engine: { opts: { cors: { methods: ['GET'] } } } } as any; + + gateway.afterInit(server); + + expect(server.engine.opts.cors).toEqual({ + methods: ['GET'], + origin: 'http://localhost:5173', + credentials: true, + }); + }); + + it('afterInit safely no-ops when engine options are unavailable', () => { + const { gateway } = makeGateway(); + expect(() => gateway.afterInit({} as any)).not.toThrow(); + }); + + it('handleConnection rejects missing token and disconnects socket', async () => { + const { gateway } = makeGateway(); + const client = { + id: 'sock-1', + handshake: { auth: {} }, + emit: vi.fn(), + disconnect: vi.fn(), + } as any; + + await gateway.handleConnection(client); + + expect(client.disconnect).toHaveBeenCalledTimes(1); + expect(client.emit).not.toHaveBeenCalled(); + }); + + it('handleConnection emits snapshot when user has required permission', async () => { + const { gateway, jwtService, authService, queueRepo, enrichmentConfig, session } = makeGateway(); + const user = { id: 11, isSuperuser: false, permissions: [Permission.ManageMetadataConfig] }; + jwtService.verify.mockReturnValue({ sub: 11, ver: 2 }); + authService.validateUser.mockResolvedValue(user); + queueRepo.getStatusSummary.mockResolvedValue({ queued: 3, processing: 1, rateLimited: 0, failed: 0, done: 2, total: 6 }); + enrichmentConfig.isPaused.mockResolvedValue(true); + session.getSnapshot.mockReturnValue({ sessionTotal: 6, sessionDone: 2, currentItemName: 'Alice' }); + const client = { + id: 'sock-2', + handshake: { auth: { token: 'jwt' } }, + emit: vi.fn(), + disconnect: vi.fn(), + } as any; + + await gateway.handleConnection(client); + + expect(authService.validateUser).toHaveBeenCalledWith(11, 2); + expect(client.emit).toHaveBeenCalledWith(AUTHOR_ENRICHMENT_STATUS_EVENT, { + queued: 3, + processing: 1, + rateLimited: 0, + failed: 0, + done: 2, + total: 6, + paused: true, + sessionTotal: 6, + sessionDone: 2, + currentItemName: 'Alice', + }); + expect(client.disconnect).not.toHaveBeenCalled(); + }); + + it('handleConnection rejects connected users without enrichment-status permission', async () => { + const { gateway, jwtService, authService } = makeGateway(); + jwtService.verify.mockReturnValue({ sub: 7, ver: 1 }); + authService.validateUser.mockResolvedValue({ id: 7, isSuperuser: false, permissions: [] }); + const client = { + id: 'sock-3', + handshake: { auth: { token: 'jwt' } }, + emit: vi.fn(), + disconnect: vi.fn(), + } as any; + + await gateway.handleConnection(client); + + expect(client.disconnect).toHaveBeenCalledTimes(1); + expect(client.emit).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/modules/authors/author-enrichment.repository.test.ts b/server/src/modules/authors/author-enrichment.repository.test.ts index 3b779b6b4..50895073c 100644 --- a/server/src/modules/authors/author-enrichment.repository.test.ts +++ b/server/src/modules/authors/author-enrichment.repository.test.ts @@ -55,15 +55,24 @@ describe('AuthorEnrichmentRepository', () => { updateBuilder.where.mockReturnValue(updateBuilder); updateBuilder.returning.mockResolvedValue([]); + const deleteBuilder = { + where: vi.fn(), + returning: vi.fn(), + }; + deleteBuilder.where.mockReturnValue(deleteBuilder); + deleteBuilder.returning.mockResolvedValue([]); + return { insertBuilder, selectBuilder, updateBuilder, + deleteBuilder, db: { insert: vi.fn().mockReturnValue(insertBuilder), select: vi.fn().mockReturnValue(selectBuilder), selectDistinct: vi.fn().mockReturnValue(selectBuilder), update: vi.fn().mockReturnValue(updateBuilder), + delete: vi.fn().mockReturnValue(deleteBuilder), }, }; }; @@ -237,4 +246,88 @@ describe('AuthorEnrichmentRepository', () => { total: 10, }); }); + + it('filterEligibleAuthorIds short-circuits empty ids or disabled conditions', async () => { + const { db } = makeDb(); + const repo = new AuthorEnrichmentRepository(db as never); + + await expect(repo.filterEligibleAuthorIds([], { neverEnriched: true, missingBio: false, missingPhoto: false })).resolves.toEqual([]); + await expect(repo.filterEligibleAuthorIds([1, 2], { neverEnriched: false, missingBio: false, missingPhoto: false })).resolves.toEqual([]); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('filterEligibleAuthorIds returns only ids matching built predicates', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.where.mockResolvedValueOnce([{ id: 2 }, { id: 5 }]); + const repo = new AuthorEnrichmentRepository(db as never); + + await expect(repo.filterEligibleAuthorIds([1, 2, 5], { neverEnriched: true, missingBio: false, missingPhoto: false })).resolves.toEqual([2, 5]); + }); + + it('enqueueAllLinkedAuthors schedules distinct linked author ids', async () => { + const { db, selectBuilder, insertBuilder } = makeDb(); + selectBuilder.from.mockResolvedValueOnce([{ authorId: 11 }, { authorId: 12 }]); + insertBuilder.returning.mockResolvedValueOnce([{ authorId: 11 }, { authorId: 12 }]); + const repo = new AuthorEnrichmentRepository(db as never); + + await expect(repo.enqueueAllLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL)).resolves.toBe(2); + expect(db.selectDistinct).toHaveBeenCalled(); + }); + + it('markDone clears queue row and updates author enrichment fields', async () => { + const { db, deleteBuilder, updateBuilder } = makeDb(); + const repo = new AuthorEnrichmentRepository(db as never); + deleteBuilder.where.mockResolvedValueOnce(undefined); + updateBuilder.where.mockResolvedValueOnce(undefined); + + await repo.markDone(44, true); + + expect(db.delete).toHaveBeenCalled(); + expect(updateBuilder.set).toHaveBeenCalledWith( + expect.objectContaining({ + lastEnrichedAt: expect.any(Date), + hasPhoto: true, + }), + ); + }); + + it('cancelPending, requeueFailed, and resetAllProcessingOnBoot return affected counts', async () => { + const { db, deleteBuilder, updateBuilder } = makeDb(); + const repo = new AuthorEnrichmentRepository(db as never); + deleteBuilder.returning.mockResolvedValueOnce([{ authorId: 1 }]); + updateBuilder.returning.mockResolvedValueOnce([{ authorId: 2 }, { authorId: 3 }]).mockResolvedValueOnce([{ authorId: 4 }]); + + await expect(repo.cancelPending()).resolves.toBe(1); + await expect(repo.requeueFailed()).resolves.toBe(2); + await expect(repo.resetAllProcessingOnBoot()).resolves.toBe(1); + }); + + it('getFailedItems maps failed rows and total count', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.where.mockReturnValueOnce(selectBuilder).mockResolvedValueOnce([{ cnt: 1 }]); + selectBuilder.limit.mockReturnValueOnce(selectBuilder); + selectBuilder.offset.mockResolvedValueOnce([ + { + authorId: 7, + name: 'Author Seven', + error: 'provider timeout', + httpStatus: 504, + failedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]); + const repo = new AuthorEnrichmentRepository(db as never); + + await expect(repo.getFailedItems(1, 20)).resolves.toEqual({ + items: [ + { + authorId: 7, + name: 'Author Seven', + error: 'provider timeout', + httpStatus: 504, + failedAt: '2026-01-01T00:00:00.000Z', + }, + ], + total: 1, + }); + }); }); diff --git a/server/src/modules/authors/author-image-storage.service.test.ts b/server/src/modules/authors/author-image-storage.service.test.ts index 9c6a60555..4226ddc2e 100644 --- a/server/src/modules/authors/author-image-storage.service.test.ts +++ b/server/src/modules/authors/author-image-storage.service.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { lookup } from 'dns/promises'; +import { generateThumbnail, imageExt } from '../metadata/lib/cover'; import { AuthorImageStorageService } from './author-image-storage.service'; @@ -53,6 +54,14 @@ describe('AuthorImageStorageService', () => { expect(global.fetch).not.toHaveBeenCalled(); }); + it('rejects non-http protocols and private IPv6 loopback URLs', async () => { + global.fetch = vi.fn(); + + await expect(service.saveFromUrl(1, 'ftp://example.com/image.jpg')).resolves.toBe(false); + await expect(service.saveFromUrl(1, 'http://[::1]/image.jpg')).resolves.toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + it('saves image and thumbnail for public hosts and removes old photo files', async () => { vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); global.fetch = vi.fn().mockResolvedValue({ @@ -129,4 +138,204 @@ describe('AuthorImageStorageService', () => { retryAfterMs: 30_000, }); }); + + it('returns false for empty or invalid URLs', async () => { + global.fetch = vi.fn(); + + await expect(service.saveFromUrl(1, ' ')).resolves.toBe(false); + await expect(service.saveFromUrl(1, 'not-a-url')).resolves.toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('normalizes protocol-relative URLs to https', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + headers: { get: vi.fn().mockReturnValue('image/jpeg') }, + body: (async function* () { + await Promise.resolve(); + yield Buffer.from('protocol-relative-image'); + })(), + } as never); + + await expect(service.saveFromUrl(3, '//cdn.example.com/a.jpg')).resolves.toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://cdn.example.com/a.jpg'), + expect.objectContaining({ redirect: 'manual' }), + ); + }); + + it('returns false for non-image responses, missing bodies, and non-ok statuses', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi + .fn() + .mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: vi.fn().mockReturnValue('application/json') }, + body: (async function* () { + await Promise.resolve(); + yield Buffer.from('{}'); + })(), + } as never) + .mockResolvedValueOnce({ + status: 200, + ok: true, + headers: { get: vi.fn().mockReturnValue('image/jpeg') }, + body: null, + } as never) + .mockResolvedValueOnce({ + status: 404, + ok: false, + headers: { get: vi.fn().mockReturnValue('image/jpeg') }, + } as never); + + await expect(service.saveFromUrl(10, 'https://cdn.example.com/a.jpg')).resolves.toBe(false); + await expect(service.saveFromUrl(10, 'https://cdn.example.com/b.jpg')).resolves.toBe(false); + await expect(service.saveFromUrl(10, 'https://cdn.example.com/c.jpg')).resolves.toBe(false); + }); + + it('returns false when image payload exceeds hard size limit', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + headers: { get: vi.fn().mockReturnValue('image/jpeg') }, + body: (async function* () { + await Promise.resolve(); + yield Buffer.alloc(11 * 1024 * 1024, 1); + yield Buffer.alloc(11 * 1024 * 1024, 1); + })(), + } as never); + + await expect(service.saveFromUrl(12, 'https://cdn.example.com/large.jpg')).resolves.toBe(false); + }); + + it('returns false on redirects without location', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi.fn().mockResolvedValue({ + status: 302, + ok: false, + headers: { get: vi.fn().mockReturnValue(null) }, + } as never); + + await expect(service.saveFromUrl(13, 'https://cdn.example.com/redirect.jpg')).resolves.toBe(false); + }); + + it('returns false when redirect location is invalid', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi.fn().mockResolvedValue({ + status: 302, + ok: false, + headers: { get: vi.fn((name: string) => (name === 'location' ? 'http://%' : null)) }, + } as never); + + await expect(service.saveFromUrl(13, 'https://cdn.example.com/redirect.jpg')).resolves.toBe(false); + }); + + it('throws non-transient error when redirect limit is exceeded', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi.fn().mockResolvedValue({ + status: 302, + ok: false, + headers: { get: vi.fn((name: string) => (name === 'location' ? 'https://cdn.example.com/again.jpg' : null)) }, + } as never); + + await expect(service.saveFromUrl(14, 'https://cdn.example.com/first.jpg')).rejects.toMatchObject({ + name: 'AuthorImageStorageError', + transient: false, + message: 'Too many image redirects', + }); + }); + + it('wraps unexpected fetch failures in typed transient errors', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi.fn().mockRejectedValue(new Error('network down')); + + await expect(service.saveFromUrl(15, 'https://cdn.example.com/image.jpg')).rejects.toMatchObject({ + name: 'AuthorImageStorageError', + transient: true, + httpStatus: null, + retryAfterMs: null, + }); + }); + + it('rejects URL when DNS resolution fails', async () => { + vi.mocked(lookup).mockRejectedValueOnce(new Error('dns lookup failed')); + global.fetch = vi.fn(); + + await expect(service.saveFromUrl(15, 'https://cdn.example.com/image.jpg')).resolves.toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('parses HTTP-date retry-after values for 5xx failures', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + const retryAt = new Date(Date.now() + 60_000).toUTCString(); + global.fetch = vi.fn().mockResolvedValue({ + status: 503, + ok: false, + headers: { get: vi.fn((name: string) => (name === 'retry-after' ? retryAt : null)) }, + } as never); + + await expect(service.saveFromUrl(15, 'https://cdn.example.com/image.jpg')).rejects.toMatchObject({ + name: 'AuthorImageStorageError', + transient: true, + httpStatus: 503, + retryAfterMs: expect.any(Number), + }); + }); + + it('throws typed error when persist pipeline fails after download', async () => { + vi.mocked(lookup).mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + global.fetch = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + headers: { get: vi.fn().mockReturnValue('image/jpeg') }, + body: (async function* () { + await Promise.resolve(); + yield Buffer.from('image'); + })(), + } as never); + vi.mocked(imageExt).mockImplementationOnce(() => { + throw new Error('unrecognized image'); + }); + + await expect(service.saveFromUrl(16, 'https://cdn.example.com/image.jpg')).rejects.toMatchObject({ + name: 'AuthorImageStorageError', + transient: true, + message: expect.stringContaining('Failed to persist author image'), + }); + expect(generateThumbnail).not.toHaveBeenCalled(); + }); + + it('returns disk paths and cache-busted URLs when files exist', async () => { + const authorDir = join(booksPath, 'authors', '21'); + await mkdir(authorDir, { recursive: true }); + await writeFile(join(authorDir, 'photo.png'), Buffer.from('img')); + await writeFile(join(authorDir, 'thumbnail.jpg'), Buffer.from('thumb')); + + const imagePath = await service.getImagePath(21); + const thumbPath = await service.getThumbnailPath(21); + const imageUrl = await service.getImageUrlIfExists(21); + const thumbUrl = await service.getThumbnailUrlIfExists(21); + + expect(imagePath).toBe(join(authorDir, 'photo.png')); + expect(thumbPath).toBe(join(authorDir, 'thumbnail.jpg')); + expect(imageUrl).toMatch(/^\/api\/v1\/authors\/21\/image\?t=\d+$/); + expect(thumbUrl).toMatch(/^\/api\/v1\/authors\/21\/thumbnail\?t=\d+$/); + }); + + it('returns null paths when image files are missing', async () => { + await expect(service.getImagePath(777)).resolves.toBeNull(); + await expect(service.getThumbnailPath(777)).resolves.toBeNull(); + }); + + it('falls back to plain image and thumbnail URLs when stat fails', async () => { + vi.spyOn(service, 'getImagePath').mockResolvedValue('/tmp/missing-image.jpg'); + vi.spyOn(service, 'getThumbnailPath').mockResolvedValue('/tmp/missing-thumb.jpg'); + + await expect(service.getImageUrlIfExists(22)).resolves.toBe('/api/v1/authors/22/image'); + await expect(service.getThumbnailUrlIfExists(22)).resolves.toBe('/api/v1/authors/22/thumbnail'); + }); }); diff --git a/server/src/modules/authors/authors.controller.test.ts b/server/src/modules/authors/authors.controller.test.ts new file mode 100644 index 000000000..12b6c89c7 --- /dev/null +++ b/server/src/modules/authors/authors.controller.test.ts @@ -0,0 +1,299 @@ +import 'reflect-metadata'; + +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import { of } from 'rxjs'; + +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; +import type { RequestUser } from '../../common/types/request-user'; +import { AuthorsController } from './authors.controller'; + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + createReadStream: vi.fn(() => ({ stream: true })), + }; +}); + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises'); + return { + ...actual, + stat: vi.fn(), + }; +}); + +function makeUser(overrides?: Partial): RequestUser { + return { + id: 11, + username: 'author-admin', + name: 'Author Admin', + email: null, + active: true, + isSuperuser: false, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + ...overrides, + }; +} + +function makeReply() { + const headers: Record = {}; + const raw = { + destroyed: false, + writableEnded: false, + writeHead: vi.fn(), + write: vi.fn(), + end: vi.fn(() => { + raw.writableEnded = true; + }), + }; + + const reply = { + raw, + status: vi.fn(), + header: vi.fn(), + type: vi.fn(), + send: vi.fn(), + }; + + reply.status.mockImplementation(() => reply as never); + reply.header.mockImplementation((key: string, value: unknown) => { + headers[key] = value; + return reply as never; + }); + reply.type.mockImplementation(() => reply as never); + reply.send.mockImplementation(() => reply as never); + + return { reply: reply as never, raw, headers }; +} + +function makeController() { + const authorsService = { + findAll: vi.fn(), + getInsights: vi.fn(), + listDuplicateSuggestions: vi.fn(), + listMetadataProviders: vi.fn(), + searchMetadata: vi.fn(), + streamMetadata: vi.fn(), + lookupMetadata: vi.fn(), + bulkRefreshMetadata: vi.fn(), + findOne: vi.fn(), + findBooks: vi.fn(), + getThumbnailPath: vi.fn(), + getImagePath: vi.fn(), + refreshEnrichment: vi.fn(), + update: vi.fn(), + merge: vi.fn(), + delete: vi.fn(), + }; + const enrichmentOrchestrator = { + backfillLinkedAuthors: vi.fn(), + backfillAllLinkedAuthors: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + cancelPending: vi.fn(), + requeueFailed: vi.fn(), + getFailedItems: vi.fn(), + }; + const enrichmentConfig = { + getConfig: vi.fn(), + setConfig: vi.fn(), + }; + const queueRepo = { + countEligibleLinkedAuthors: vi.fn(), + }; + + const controller = new AuthorsController(authorsService as never, enrichmentOrchestrator as never, enrichmentConfig as never, queueRepo as never); + + return { controller, authorsService, enrichmentOrchestrator, enrichmentConfig, queueRepo }; +} + +describe('AuthorsController', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('delegates list and detail endpoints to AuthorsService', async () => { + const { controller, authorsService } = makeController(); + const user = makeUser(); + authorsService.findAll.mockResolvedValue({ items: [], total: 0 }); + authorsService.findOne.mockResolvedValue({ id: 2, name: 'A' }); + authorsService.findBooks.mockResolvedValue({ items: [], total: 0 }); + + await expect(controller.findAll(user, { page: 0 })).resolves.toEqual({ items: [], total: 0 }); + await expect(controller.findOne(user, 2)).resolves.toEqual({ id: 2, name: 'A' }); + await expect(controller.findBooks(user, 2, { page: 0 })).resolves.toEqual({ items: [], total: 0 }); + + expect(authorsService.findAll).toHaveBeenCalledWith(user, { page: 0 }); + expect(authorsService.findOne).toHaveBeenCalledWith(user, 2); + expect(authorsService.findBooks).toHaveBeenCalledWith(user, 2, { page: 0 }); + }); + + it('returns metadata provider lists/searches and maps metadata stream events', async () => { + const { controller, authorsService } = makeController(); + const candidate = { provider: 'audnexus', providerId: 'x1', name: 'Jane Doe' }; + authorsService.listMetadataProviders.mockResolvedValue([{ key: 'audnexus' }]); + authorsService.searchMetadata.mockResolvedValue([candidate]); + authorsService.lookupMetadata.mockResolvedValue(candidate); + authorsService.streamMetadata.mockReturnValue(of(candidate)); + + await expect(controller.listMetadataProviders()).resolves.toEqual([{ key: 'audnexus' }]); + await expect(controller.searchMetadata({ q: 'Jane' })).resolves.toEqual([candidate]); + await expect(controller.lookupMetadata({ provider: 'audnexus', id: 'x1' })).resolves.toEqual(candidate); + + const events = await new Promise((resolve, reject) => { + const rows: unknown[] = []; + controller.streamMetadata({ q: 'Jane' }).subscribe({ + next: (row) => rows.push(row), + error: reject, + complete: () => resolve(rows), + }); + }); + + expect(events).toEqual([{ data: candidate }]); + }); + + it('streams SSE progress and completion for bulk refresh', async () => { + const { controller, authorsService } = makeController(); + const { reply, raw } = makeReply(); + const user = makeUser(); + authorsService.bulkRefreshMetadata.mockImplementation((_authorIds: number[], _u: RequestUser, onProgress: (event: object) => void) => { + onProgress({ authorId: 5, status: 'queued' }); + return Promise.resolve({ queued: 1, processed: 1, failed: 0 }); + }); + + await controller.bulkRefreshMetadata({ authorIds: [5] }, user, reply); + + expect(raw.writeHead).toHaveBeenCalledWith(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + expect(raw.write).toHaveBeenNthCalledWith(1, `data: ${JSON.stringify({ authorId: 5, status: 'queued' })}\n\n`); + expect(raw.write).toHaveBeenNthCalledWith(2, `data: ${JSON.stringify({ done: true, queued: 1, processed: 1, failed: 0 })}\n\n`); + expect(raw.end).toHaveBeenCalled(); + }); + + it('returns enrichment config values and updates them', async () => { + const { controller, enrichmentConfig } = makeController(); + const config = { + enabled: true, + triggerOnImport: false, + writeMode: 'missing_only', + conditions: { neverEnriched: true, missingBio: true, missingPhoto: false }, + }; + enrichmentConfig.getConfig.mockResolvedValue(config); + enrichmentConfig.setConfig.mockResolvedValue(undefined); + + await expect(controller.getEnrichmentConfig()).resolves.toEqual(config); + await expect(controller.setEnrichmentConfig(config)).resolves.toEqual(config); + + expect(enrichmentConfig.setConfig).toHaveBeenCalledWith(config); + expect(enrichmentConfig.getConfig).toHaveBeenCalledTimes(2); + }); + + it('delegates orchestration and queue endpoints', async () => { + const { controller, enrichmentOrchestrator, queueRepo } = makeController(); + queueRepo.countEligibleLinkedAuthors.mockResolvedValue(12); + enrichmentOrchestrator.backfillLinkedAuthors.mockResolvedValue(8); + enrichmentOrchestrator.backfillAllLinkedAuthors.mockResolvedValue(15); + enrichmentOrchestrator.pause.mockResolvedValue(undefined); + enrichmentOrchestrator.resume.mockResolvedValue(undefined); + enrichmentOrchestrator.cancelPending.mockResolvedValue(undefined); + enrichmentOrchestrator.requeueFailed.mockResolvedValue(3); + enrichmentOrchestrator.getFailedItems.mockResolvedValue({ items: [], total: 0, page: 2, limit: 100 }); + + await expect(controller.previewCount({ conditions: { neverEnriched: true, missingBio: false, missingPhoto: true } })).resolves.toEqual({ + count: 12, + }); + await expect(controller.enqueueBackfill()).resolves.toEqual({ queued: 8 }); + await expect(controller.enqueueBackfillAll()).resolves.toEqual({ queued: 15 }); + await expect(controller.pauseEnrichment()).resolves.toEqual({ paused: true }); + await expect(controller.resumeEnrichment()).resolves.toEqual({ paused: false }); + await expect(controller.cancelEnrichment()).resolves.toEqual({ cancelled: true }); + await expect(controller.retryFailedEnrichment()).resolves.toEqual({ requeued: 3 }); + await expect(controller.getFailedEnrichment(2, 500)).resolves.toEqual({ items: [], total: 0, page: 2, limit: 100 }); + + expect(enrichmentOrchestrator.getFailedItems).toHaveBeenCalledWith(2, 100); + }); + + it('serves thumbnail/image streams with cache and content headers', async () => { + const { controller, authorsService } = makeController(); + const { reply: thumbnailReply, headers: thumbnailHeaders } = makeReply(); + const { reply: imageReply, headers: imageHeaders } = makeReply(); + const user = makeUser(); + vi.mocked(stat).mockResolvedValue({ mtimeMs: 1234 } as never); + vi.mocked(createReadStream).mockReturnValue({ stream: true } as never); + + authorsService.getThumbnailPath.mockResolvedValue('/tmp/author-2-thumb.jpg'); + authorsService.getImagePath.mockResolvedValue('/tmp/author-2-image.png'); + + await controller.getThumbnail(user, 2, thumbnailReply); + await controller.getImage(user, 2, imageReply); + + expect(thumbnailHeaders['Cache-Control']).toBe('no-cache'); + expect(thumbnailHeaders['ETag']).toBe('"1234"'); + expect(thumbnailReply.type).toHaveBeenCalledWith('image/jpeg'); + + expect(imageHeaders['Cache-Control']).toBe('no-cache'); + expect(imageHeaders['ETag']).toBe('"1234"'); + expect(imageReply.type).toHaveBeenCalledWith('image/png'); + expect(createReadStream).toHaveBeenCalledWith('/tmp/author-2-image.png'); + }); + + it('returns 404 payloads when thumbnail/image files are missing', async () => { + const { controller, authorsService } = makeController(); + const { reply: thumbnailReply } = makeReply(); + const { reply: imageReply } = makeReply(); + const user = makeUser(); + + authorsService.getThumbnailPath.mockResolvedValue(null); + authorsService.getImagePath.mockResolvedValue(null); + + await controller.getThumbnail(user, 44, thumbnailReply); + await controller.getImage(user, 55, imageReply); + + expect(thumbnailReply.status).toHaveBeenCalledWith(404); + expect(thumbnailReply.send).toHaveBeenCalledWith({ message: 'No thumbnail for author 44' }); + expect(imageReply.status).toHaveBeenCalledWith(404); + expect(imageReply.send).toHaveBeenCalledWith({ message: 'No image for author 55' }); + }); + + it('delegates write endpoints for refresh/update/merge/delete', async () => { + const { controller, authorsService } = makeController(); + const user = makeUser(); + authorsService.refreshEnrichment.mockResolvedValue({ id: 9 }); + authorsService.update.mockResolvedValue({ id: 9, name: 'Updated' }); + authorsService.merge.mockResolvedValue({ mergedAuthorIds: [2], affectedBookCount: 3 }); + authorsService.delete.mockResolvedValue({ deletedAuthorIds: [2], affectedBookCount: 3 }); + + await expect(controller.refreshEnrichment(user, 9)).resolves.toEqual({ id: 9 }); + await expect(controller.update(user, 9, { name: 'Updated' })).resolves.toEqual({ id: 9, name: 'Updated' }); + await expect(controller.merge(user, { targetAuthorId: 9, sourceAuthorIds: [2] })).resolves.toEqual({ + mergedAuthorIds: [2], + affectedBookCount: 3, + }); + await expect(controller.delete(user, { authorIds: [2] })).resolves.toEqual({ deletedAuthorIds: [2], affectedBookCount: 3 }); + }); + + it('uses correct merge audit description keys from DTO payload', () => { + const options = Reflect.getMetadata(AUDITABLE_KEY, AuthorsController.prototype.merge) as { + description: (req: { body: Record }) => string; + }; + + const description = options.description({ + body: { + sourceAuthorIds: [3, 4], + targetAuthorId: 7, + }, + }); + + expect(description).toBe('Merged 2 author(s) into author #7'); + }); +}); diff --git a/server/src/modules/authors/authors.controller.ts b/server/src/modules/authors/authors.controller.ts index cf7b39f08..037552cf2 100644 --- a/server/src/modules/authors/authors.controller.ts +++ b/server/src/modules/authors/authors.controller.ts @@ -256,8 +256,8 @@ export class AuthorsController { action: AuditAction.AuthorMerge, resource: AuditResource.Author, description: (req) => { - const body = req.body as { sourceIds?: number[]; targetId?: number }; - return `Merged ${body?.sourceIds?.length ?? 0} author(s) into author #${body?.targetId ?? 'unknown'}`; + const body = req.body as { sourceAuthorIds?: number[]; targetAuthorId?: number }; + return `Merged ${body?.sourceAuthorIds?.length ?? 0} author(s) into author #${body?.targetAuthorId ?? 'unknown'}`; }, }) merge(@CurrentUser() user: RequestUser, @Body() dto: MergeAuthorsDto) { diff --git a/server/src/modules/authors/authors.module.test.ts b/server/src/modules/authors/authors.module.test.ts new file mode 100644 index 000000000..e9851ba1c --- /dev/null +++ b/server/src/modules/authors/authors.module.test.ts @@ -0,0 +1,32 @@ +import 'reflect-metadata'; + +vi.mock('../app-settings/app-settings.module', () => ({ AppSettingsModule: class AppSettingsModule {} })); +vi.mock('../auth/auth.module', () => ({ AuthModule: class AuthModule {} })); +vi.mock('../book/book.module', () => ({ BookModule: class BookModule {} })); +vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} })); +vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} })); + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { AuthorsController } from './authors.controller'; +import { AuthorsModule } from './authors.module'; +import { AuthorsRepository } from './authors.repository'; +import { AuthorsService } from './authors.service'; +import { AUTHOR_METADATA_PROVIDERS } from './metadata/constants'; + +describe('AuthorsModule', () => { + it('registers expected controller and provider graph', () => { + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AuthorsModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AuthorsModule) as Array; + + expect(controllers).toEqual([AuthorsController]); + expect(providers).toEqual(expect.arrayContaining([AuthorsService, AuthorsRepository])); + expect(providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provide: AUTHOR_METADATA_PROVIDERS, + }), + ]), + ); + }); +}); diff --git a/server/src/modules/authors/authors.repository.test.ts b/server/src/modules/authors/authors.repository.test.ts index dd3417477..a15c33962 100644 --- a/server/src/modules/authors/authors.repository.test.ts +++ b/server/src/modules/authors/authors.repository.test.ts @@ -11,13 +11,89 @@ vi.mock('drizzle-orm', () => ({ isNull: vi.fn((value: unknown) => ({ op: 'isNull', value })), max: vi.fn((value: unknown) => ({ op: 'max', value })), or: vi.fn((...clauses: unknown[]) => ({ op: 'or', clauses })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })), + { + raw: vi.fn((value: string) => ({ op: 'sql.raw', value })), + }, + ), })); import { eq, sql } from 'drizzle-orm'; import { AuthorsRepository } from './authors.repository'; +function makeDb() { + const selectBuilder = { + from: vi.fn(), + where: vi.fn(), + innerJoin: vi.fn(), + leftJoin: vi.fn(), + groupBy: vi.fn(), + having: vi.fn(), + as: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + offset: vi.fn(), + }; + selectBuilder.from.mockReturnValue(selectBuilder); + selectBuilder.where.mockReturnValue(selectBuilder); + selectBuilder.innerJoin.mockReturnValue(selectBuilder); + selectBuilder.leftJoin.mockReturnValue(selectBuilder); + selectBuilder.groupBy.mockReturnValue(selectBuilder); + selectBuilder.having.mockReturnValue(selectBuilder); + selectBuilder.as.mockReturnValue(selectBuilder); + selectBuilder.orderBy.mockReturnValue(selectBuilder); + selectBuilder.offset.mockResolvedValue([]); + selectBuilder.limit.mockReturnValue(selectBuilder); + + const selectDistinctBuilder = { + from: vi.fn(), + innerJoin: vi.fn(), + where: vi.fn(), + }; + selectDistinctBuilder.from.mockReturnValue(selectDistinctBuilder); + selectDistinctBuilder.innerJoin.mockReturnValue(selectDistinctBuilder); + selectDistinctBuilder.where.mockResolvedValue([]); + + const updateBuilder = { + set: vi.fn(), + where: vi.fn(), + returning: vi.fn(), + }; + updateBuilder.set.mockReturnValue(updateBuilder); + updateBuilder.where.mockReturnValue(updateBuilder); + updateBuilder.returning.mockResolvedValue([]); + + const deleteBuilder = { + where: vi.fn(), + }; + deleteBuilder.where.mockResolvedValue(undefined); + + const insertBuilder = { + values: vi.fn(), + onConflictDoNothing: vi.fn(), + }; + insertBuilder.values.mockReturnValue(insertBuilder); + insertBuilder.onConflictDoNothing.mockResolvedValue(undefined); + + return { + db: { + select: vi.fn().mockReturnValue(selectBuilder), + selectDistinct: vi.fn().mockReturnValue(selectDistinctBuilder), + update: vi.fn().mockReturnValue(updateBuilder), + insert: vi.fn().mockReturnValue(insertBuilder), + delete: vi.fn().mockReturnValue(deleteBuilder), + transaction: vi.fn().mockResolvedValue(undefined), + }, + selectBuilder, + selectDistinctBuilder, + updateBuilder, + insertBuilder, + deleteBuilder, + }; +} + describe('AuthorsRepository', () => { it('updateAuthorDescriptionIfEmpty treats whitespace-only descriptions as empty', async () => { const updateBuilder = { @@ -40,4 +116,320 @@ describe('AuthorsRepository', () => { expect(eq).toHaveBeenCalledWith(expect.objectContaining({ op: 'sql' }), ''); expect(updateBuilder.returning).toHaveBeenCalled(); }); + + it('returns early defaults for empty-library and empty-id short-circuit branches', async () => { + const { db } = makeDb(); + const repo = new AuthorsRepository(db as never); + + await expect(repo.findById(1, [])).resolves.toBeNull(); + await expect(repo.findBookIdsPage({ authorId: 1, page: 0, size: 10, sort: 'title', order: 'asc', libraryIds: [] })).resolves.toEqual({ + bookIds: [], + total: 0, + page: 0, + size: 10, + }); + await expect(repo.findVisibleAuthorIds([], [1])).resolves.toEqual([]); + await expect(repo.findVisibleAuthorIds([1], [])).resolves.toEqual([]); + await expect(repo.countDistinctBooks([])).resolves.toBe(0); + await expect(repo.findAuthorsForDuplicatePool([], 10)).resolves.toEqual([]); + await expect(repo.findAuthorsAddedSince([], new Date(), 10)).resolves.toEqual([]); + await expect(repo.findMostReadAuthors([], new Date(), 10)).resolves.toEqual([]); + await expect(repo.findAuthorBookPairs([])).resolves.toEqual([]); + await expect(repo.findStartedBookIdsForUser(1, [])).resolves.toEqual([]); + await expect(repo.findRelatedLibraryIds([])).resolves.toEqual([]); + await expect(repo.mergeAuthors(1, [])).resolves.toBeUndefined(); + await expect(repo.deleteAuthors([])).resolves.toBeUndefined(); + + expect(db.select).not.toHaveBeenCalled(); + expect(db.selectDistinct).not.toHaveBeenCalled(); + expect(db.transaction).not.toHaveBeenCalled(); + }); + + it('maps distinct ids for visibility and related-library lookup queries', async () => { + const { db, selectDistinctBuilder } = makeDb(); + selectDistinctBuilder.where.mockResolvedValueOnce([{ id: 5 }, { id: 8 }]).mockResolvedValueOnce([{ libraryId: 11 }, { libraryId: 12 }]); + const repo = new AuthorsRepository(db as never); + + await expect(repo.findVisibleAuthorIds([5, 8], [100])).resolves.toEqual([5, 8]); + await expect(repo.findRelatedLibraryIds([1, 2])).resolves.toEqual([11, 12]); + }); + + it('converts sql numeric aggregates from strings to numbers', async () => { + const { db, selectBuilder, selectDistinctBuilder } = makeDb(); + selectBuilder.where.mockResolvedValueOnce([{ total: '4' }]); + selectDistinctBuilder.where.mockResolvedValueOnce([{ bookId: 91 }, { bookId: 92 }]); + const repo = new AuthorsRepository(db as never); + + await expect(repo.countDistinctBooks([1, 2])).resolves.toBe(4); + await expect(repo.findStartedBookIdsForUser(7, [2])).resolves.toEqual([91, 92]); + }); + + it('findByIdForEnrichment returns null when no row is found', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.limit.mockResolvedValue([]); + const repo = new AuthorsRepository(db as never); + + await expect(repo.findByIdForEnrichment(77)).resolves.toBeNull(); + }); + + it('findPage maps rows and total for standard count branch', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.where.mockReturnValueOnce(selectBuilder).mockResolvedValueOnce([{ total: '3' }]); + selectBuilder.offset.mockResolvedValueOnce([ + { + id: 1, + name: 'A', + sortName: 'A', + description: null, + bookCount: 2, + lastAddedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]); + const repo = new AuthorsRepository(db as never); + + await expect( + repo.findPage({ + page: 0, + size: 20, + sort: 'name', + order: 'asc', + libraryIds: [1], + q: 'au', + hasPhoto: true, + }), + ).resolves.toEqual({ + items: [ + { + id: 1, + name: 'A', + sortName: 'A', + description: null, + bookCount: 2, + lastAddedAt: new Date('2026-01-01T00:00:00Z'), + }, + ], + total: 3, + page: 0, + size: 20, + }); + }); + + it('findPage uses grouped subquery count when minBookCount is provided', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.from + .mockReturnValueOnce(selectBuilder) + .mockReturnValueOnce(selectBuilder) + .mockResolvedValueOnce([{ total: '2' }]); + selectBuilder.where.mockReturnValue(selectBuilder); + selectBuilder.offset.mockResolvedValueOnce([{ id: 1 }]); + const repo = new AuthorsRepository(db as never); + + await expect( + repo.findPage({ + page: 1, + size: 10, + sort: 'bookCount', + order: 'desc', + libraryIds: [1, 2], + minBookCount: 3, + }), + ).resolves.toEqual({ + items: [{ id: 1 }], + total: 2, + page: 1, + size: 10, + }); + }); + + it('findById returns a summary row when the author is visible in selected libraries', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.limit.mockResolvedValueOnce([ + { + id: 9, + name: 'Visible Author', + sortName: null, + description: 'bio', + bookCount: 4, + lastAddedAt: new Date('2026-01-02T00:00:00Z'), + }, + ]); + const repo = new AuthorsRepository(db as never); + + await expect(repo.findById(9, [3])).resolves.toEqual({ + id: 9, + name: 'Visible Author', + sortName: null, + description: 'bio', + bookCount: 4, + lastAddedAt: new Date('2026-01-02T00:00:00Z'), + }); + }); + + it('findBookIdsPage maps ids and coerces total for non-empty libraries', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.where.mockReturnValueOnce(selectBuilder).mockResolvedValueOnce([{ total: '2' }]); + selectBuilder.offset.mockResolvedValueOnce([{ id: 100 }, { id: 101 }]); + const repo = new AuthorsRepository(db as never); + + await expect( + repo.findBookIdsPage({ + authorId: 1, + page: 0, + size: 5, + sort: 'publishedYear', + order: 'desc', + libraryIds: [7], + }), + ).resolves.toEqual({ + bookIds: [100, 101], + total: 2, + page: 0, + size: 5, + }); + }); + + it('updateAuthorById returns updated row or null when no row is updated', async () => { + const { db, updateBuilder } = makeDb(); + updateBuilder.returning.mockResolvedValueOnce([ + { id: 5, name: 'Renamed', sortName: null, description: null, hasPhoto: false, lastEnrichedAt: null }, + ]); + updateBuilder.returning.mockResolvedValueOnce([]); + const repo = new AuthorsRepository(db as never); + + await expect(repo.updateAuthorById(5, { name: 'Renamed' })).resolves.toEqual({ + id: 5, + name: 'Renamed', + sortName: null, + description: null, + hasPhoto: false, + lastEnrichedAt: null, + }); + await expect(repo.updateAuthorById(999, { name: 'Missing' })).resolves.toBeNull(); + }); + + it('updateAuthorDescriptionIfEmpty returns false when no row matches', async () => { + const { db, updateBuilder } = makeDb(); + updateBuilder.returning.mockResolvedValueOnce([]); + const repo = new AuthorsRepository(db as never); + + await expect(repo.updateAuthorDescriptionIfEmpty(12, 'Bio')).resolves.toBe(false); + }); + + it('returns author insight and pair query rows for non-empty libraries', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.limit + .mockResolvedValueOnce([{ id: 1, name: 'A' }]) + .mockResolvedValueOnce([{ id: 2, name: 'B' }]) + .mockResolvedValueOnce([{ id: 3, name: 'C', metric: 5 }]); + selectBuilder.where + .mockReturnValueOnce(selectBuilder) + .mockReturnValueOnce(selectBuilder) + .mockReturnValueOnce(selectBuilder) + .mockResolvedValueOnce([ + { + authorId: 10, + name: 'Pair Author', + sortName: null, + description: null, + bookId: 88, + addedAt: new Date('2026-01-03T00:00:00Z'), + }, + ]); + const repo = new AuthorsRepository(db as never); + + await expect(repo.findAuthorsForDuplicatePool([1], 10)).resolves.toEqual([{ id: 1, name: 'A' }]); + await expect(repo.findAuthorsAddedSince([1], new Date('2026-01-01T00:00:00Z'), 10)).resolves.toEqual([{ id: 2, name: 'B' }]); + await expect(repo.findMostReadAuthors([1], new Date('2026-01-01T00:00:00Z'), 10)).resolves.toEqual([{ id: 3, name: 'C', metric: 5 }]); + await expect(repo.findAuthorBookPairs([1])).resolves.toEqual([ + { + authorId: 10, + name: 'Pair Author', + sortName: null, + description: null, + bookId: 88, + addedAt: new Date('2026-01-03T00:00:00Z'), + }, + ]); + }); + + it('mergeAuthors inserts deduplicated links and deletes source relations inside one transaction', async () => { + const txSelectBuilder = { + from: vi.fn(), + where: vi.fn(), + }; + txSelectBuilder.from.mockReturnValue(txSelectBuilder); + txSelectBuilder.where.mockResolvedValue([ + { bookId: 1, displayOrder: 0 }, + { bookId: 2, displayOrder: 1 }, + ]); + const txInsertBuilder = { + values: vi.fn(), + onConflictDoNothing: vi.fn(), + }; + txInsertBuilder.values.mockReturnValue(txInsertBuilder); + txInsertBuilder.onConflictDoNothing.mockResolvedValue(undefined); + const txDeleteBuilder = { + where: vi.fn(), + }; + txDeleteBuilder.where.mockResolvedValue(undefined); + const tx = { + select: vi.fn().mockReturnValue(txSelectBuilder), + insert: vi.fn().mockReturnValue(txInsertBuilder), + delete: vi.fn().mockReturnValue(txDeleteBuilder), + }; + const db = { + transaction: vi.fn(async (handler: (client: typeof tx) => Promise) => handler(tx)), + }; + const repo = new AuthorsRepository(db as never); + + await repo.mergeAuthors(99, [1, 2]); + + expect(tx.insert).toHaveBeenCalled(); + expect(txDeleteBuilder.where).toHaveBeenCalledTimes(2); + }); + + it('mergeAuthors skips insert when source authors have no linked books', async () => { + const txSelectBuilder = { + from: vi.fn(), + where: vi.fn(), + }; + txSelectBuilder.from.mockReturnValue(txSelectBuilder); + txSelectBuilder.where.mockResolvedValue([]); + const txDeleteBuilder = { + where: vi.fn(), + }; + txDeleteBuilder.where.mockResolvedValue(undefined); + const tx = { + select: vi.fn().mockReturnValue(txSelectBuilder), + insert: vi.fn(), + delete: vi.fn().mockReturnValue(txDeleteBuilder), + }; + const db = { + transaction: vi.fn(async (handler: (client: typeof tx) => Promise) => handler(tx)), + }; + const repo = new AuthorsRepository(db as never); + + await repo.mergeAuthors(99, [1]); + + expect(tx.insert).not.toHaveBeenCalled(); + expect(tx.delete).toHaveBeenCalledTimes(2); + }); + + it('deleteAuthors removes joins and authors inside one transaction', async () => { + const txDeleteBuilder = { + where: vi.fn(), + }; + txDeleteBuilder.where.mockResolvedValue(undefined); + const tx = { + delete: vi.fn().mockReturnValue(txDeleteBuilder), + }; + const db = { + transaction: vi.fn(async (handler: (client: typeof tx) => Promise) => handler(tx)), + }; + const repo = new AuthorsRepository(db as never); + + await repo.deleteAuthors([5, 6]); + + expect(tx.delete).toHaveBeenCalledTimes(2); + }); }); diff --git a/server/src/modules/authors/authors.service.test.ts b/server/src/modules/authors/authors.service.test.ts index 6ebea0789..482be96d2 100644 --- a/server/src/modules/authors/authors.service.test.ts +++ b/server/src/modules/authors/authors.service.test.ts @@ -39,6 +39,7 @@ describe('AuthorsService', () => { listProviders: vi.fn(), search: vi.fn(), lookupById: vi.fn(), + stream: vi.fn(), quickSearch: vi.fn(), }; @@ -243,4 +244,279 @@ describe('AuthorsService', () => { await expect(service.refreshEnrichment(reqUser(), 22)).rejects.toThrow('Author enrichment failed'); }); + + it('findAll returns an empty page when user has no accessible libraries', async () => { + libraryService.findAll.mockResolvedValue([]); + + const page = await service.findAll(reqUser(), {}); + + expect(page).toEqual({ items: [], total: 0, page: 0, size: 50 }); + expect(authorsRepo.findPage).not.toHaveBeenCalled(); + }); + + it('findAll applies defaults and appends thumbnail urls', async () => { + authorsRepo.findPage.mockResolvedValue({ + items: [ + { + id: 10, + name: 'Alpha', + sortName: null, + description: null, + bookCount: 3, + lastAddedAt: null, + }, + ], + total: 1, + page: 0, + size: 50, + }); + authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue('https://cdn.example.com/a10-thumb.jpg'); + + const page = await service.findAll(reqUser(), {}); + + expect(authorsRepo.findPage).toHaveBeenCalledWith({ + q: undefined, + page: 0, + size: 50, + sort: 'name', + order: 'asc', + libraryIds: [1, 2], + hasPhoto: undefined, + minBookCount: undefined, + }); + expect(page.items[0]).toEqual( + expect.objectContaining({ + id: 10, + name: 'Alpha', + imageUrl: 'https://cdn.example.com/a10-thumb.jpg', + }), + ); + }); + + it('findAll rejects excessively deep offsets', async () => { + await expect(service.findAll(reqUser(), { page: 2_000_000, size: 100 })).rejects.toBeInstanceOf(BadRequestException); + }); + + it('findBooks returns empty page when user has no accessible libraries', async () => { + libraryService.findAll.mockResolvedValue([]); + + const page = await service.findBooks(reqUser(), 99, {}); + + expect(page).toEqual({ items: [], total: 0, page: 0, size: 50 }); + expect(authorsRepo.findById).not.toHaveBeenCalled(); + }); + + it('findBooks throws not found when the author is not visible', async () => { + authorsRepo.findById.mockResolvedValue(null); + + await expect(service.findBooks(reqUser(), 99, {})).rejects.toBeInstanceOf(NotFoundException); + }); + + it('listDuplicateSuggestions returns [] when duplicate pool is too small', async () => { + authorsRepo.findAuthorsForDuplicatePool.mockResolvedValue([ + { id: 1, name: 'Only One', sortName: null, description: null, bookCount: 1, lastAddedAt: null }, + ]); + + await expect(service.listDuplicateSuggestions(reqUser(), {})).resolves.toEqual([]); + }); + + it('listDuplicateSuggestions scores and returns likely duplicates', async () => { + authorsRepo.findAuthorsForDuplicatePool.mockResolvedValue([ + { id: 1, name: 'John Smith', sortName: null, description: null, bookCount: 7, lastAddedAt: null }, + { id: 2, name: 'John Smith', sortName: null, description: null, bookCount: 2, lastAddedAt: null }, + { id: 3, name: 'Jane Doe', sortName: null, description: null, bookCount: 5, lastAddedAt: null }, + ]); + + const suggestions = await service.listDuplicateSuggestions(reqUser(), { minConfidence: 0.9, limit: 5, poolSize: 50 }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toEqual( + expect.objectContaining({ + confidence: 0.98, + }), + ); + expect(suggestions[0]?.reasons).toContain('same canonical name'); + }); + + it('returns metadata providers directly from the metadata fetch service', () => { + authorMetadataFetchService.listProviders.mockReturnValue([{ key: 'audnexus', label: 'Audnexus', identifiable: true }]); + + expect(service.listMetadataProviders()).toEqual([{ key: 'audnexus', label: 'Audnexus', identifiable: true }]); + }); + + it('searchMetadata forwards query and provider filters', async () => { + authorMetadataFetchService.search.mockResolvedValue([{ provider: 'audnexus', providerId: 'A1', name: 'John Smith' }]); + + await expect(service.searchMetadata({ q: 'John Smith', region: 'us', limit: 2, providers: ['audnexus'] })).resolves.toEqual([ + { provider: 'audnexus', providerId: 'A1', name: 'John Smith' }, + ]); + expect(authorMetadataFetchService.search).toHaveBeenCalledWith({ name: 'John Smith', region: 'us', limit: 2 }, { keys: ['audnexus'] }); + }); + + it('lookupMetadata forwards provider key and id', async () => { + authorMetadataFetchService.lookupById.mockResolvedValue({ provider: 'audnexus', providerId: 'A1', name: 'John Smith' }); + + await expect(service.lookupMetadata({ provider: 'audnexus', id: 'A1', region: 'ca' })).resolves.toEqual({ + provider: 'audnexus', + providerId: 'A1', + name: 'John Smith', + }); + }); + + it('streamMetadata proxies through provider stream options', () => { + const mockStream = Symbol('stream'); + authorMetadataFetchService.stream.mockReturnValue(mockStream as any); + + const stream = service.streamMetadata({ q: 'Jane', region: 'us', limit: 5, providers: ['audnexus'] }); + + expect(stream).toBe(mockStream); + expect(authorMetadataFetchService.stream).toHaveBeenCalledWith({ name: 'Jane', region: 'us', limit: 5 }, { keys: ['audnexus'] }); + }); + + it('getInsights returns empty collections when no libraries are accessible', async () => { + libraryService.findAll.mockResolvedValue([]); + + const insights = await service.getInsights(reqUser(), {}); + + expect(insights.newAuthors).toEqual([]); + expect(insights.mostRead).toEqual([]); + expect(insights.unreadBacklog).toEqual([]); + }); + + it('getInsights computes unread backlog and filters mostRead rows with zero metric', async () => { + authorsRepo.findAuthorsAddedSince.mockResolvedValue([ + { + id: 1, + name: 'A', + sortName: null, + description: null, + bookCount: 2, + lastAddedAt: new Date('2026-01-01T00:00:00Z'), + metric: 2, + secondaryMetric: null, + }, + ]); + authorsRepo.findMostReadAuthors.mockResolvedValue([ + { + id: 2, + name: 'B', + sortName: null, + description: null, + bookCount: 3, + lastAddedAt: new Date('2026-01-02T00:00:00Z'), + metric: 0, + secondaryMetric: 10, + }, + { + id: 3, + name: 'C', + sortName: null, + description: null, + bookCount: 4, + lastAddedAt: new Date('2026-01-03T00:00:00Z'), + metric: 1, + secondaryMetric: 50, + }, + ]); + authorsRepo.findAuthorBookPairs.mockResolvedValue([ + { authorId: 1, name: 'A', sortName: null, description: null, bookId: 10, addedAt: new Date('2026-01-02T00:00:00Z') }, + { authorId: 1, name: 'A', sortName: null, description: null, bookId: 10, addedAt: new Date('2026-01-02T00:00:00Z') }, + { authorId: 1, name: 'A', sortName: null, description: null, bookId: 11, addedAt: new Date('2026-01-04T00:00:00Z') }, + ]); + authorsRepo.findStartedBookIdsForUser.mockResolvedValue([10]); + + const insights = await service.getInsights(reqUser(), { limit: 5, windowDays: 30 }); + + expect(insights.mostRead).toHaveLength(1); + expect(insights.mostRead[0]?.id).toBe(3); + expect(insights.unreadBacklog[0]).toEqual( + expect.objectContaining({ + id: 1, + metric: 1, + secondaryMetric: 2, + }), + ); + }); + + it('update returns current detail when no mutable fields are provided', async () => { + authorsRepo.findVisibleAuthorIds.mockResolvedValue([30]); + authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]); + authorsRepo.findById.mockResolvedValue({ + id: 30, + name: 'Current', + sortName: null, + description: null, + bookCount: 1, + lastAddedAt: null, + }); + + const result = await service.update(reqUser(), 30, {}); + + expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled(); + expect(result).toEqual(expect.objectContaining({ id: 30, name: 'Current' })); + }); + + it('update rejects blank names', async () => { + authorsRepo.findVisibleAuthorIds.mockResolvedValue([31]); + authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]); + + await expect(service.update(reqUser(), 31, { name: ' ' })).rejects.toBeInstanceOf(BadRequestException); + }); + + it('get image paths verify readability before touching storage', async () => { + authorsRepo.findVisibleAuthorIds.mockResolvedValue([40]); + authorImageStorage.getThumbnailPath.mockResolvedValue('/tmp/thumb.jpg'); + authorImageStorage.getImagePath.mockResolvedValue('/tmp/full.jpg'); + + await expect(service.getThumbnailPath(reqUser(), 40)).resolves.toBe('/tmp/thumb.jpg'); + await expect(service.getImagePath(reqUser(), 40)).resolves.toBe('/tmp/full.jpg'); + }); + + it('bulkRefreshMetadata returns zero counters for empty input', async () => { + await expect(service.bulkRefreshMetadata([], reqUser())).resolves.toEqual({ + processed: 0, + failed: 0, + updated: 0, + }); + }); + + it('bulkRefreshMetadata stops iterating when progress callback throws', async () => { + authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2]); + authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]); + authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue('https://cdn.example.com/1-thumb.jpg'); + vi.spyOn(service as any, 'refreshEnrichmentInternal').mockResolvedValue({ + descriptionUpdated: true, + imageUpdated: true, + provider: 'audnexus', + }); + + const progress = vi.fn(() => { + throw new Error('client disconnected'); + }); + + const result = await service.bulkRefreshMetadata([1, 2], reqUser(), progress); + + expect(result).toEqual({ processed: 1, failed: 0, updated: 1 }); + expect(progress).toHaveBeenCalledTimes(1); + }); + + it('bulkRefreshMetadata deduplicates ids and continues after per-item failures', async () => { + authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2]); + authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]); + vi.spyOn(service as any, 'refreshEnrichmentInternal') + .mockRejectedValueOnce(new Error('provider timeout')) + .mockResolvedValueOnce({ + descriptionUpdated: false, + imageUpdated: true, + provider: 'audnexus', + }); + vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined); + authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue('https://cdn.example.com/2-thumb.jpg'); + + const progress = vi.fn(); + const result = await service.bulkRefreshMetadata([1, 1, 2], reqUser(), progress); + + expect(result).toEqual({ processed: 2, failed: 1, updated: 1 }); + expect(progress).toHaveBeenCalledTimes(2); + }); }); diff --git a/server/src/modules/authors/dto/authors-dto-validation.test.ts b/server/src/modules/authors/dto/authors-dto-validation.test.ts new file mode 100644 index 000000000..7f10d0c9d --- /dev/null +++ b/server/src/modules/authors/dto/authors-dto-validation.test.ts @@ -0,0 +1,125 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { AuthorMetadataProviderKey } from '@projectx/types'; + +import { BulkAuthorIdsDto } from './bulk-author-ids.dto'; +import { DeleteAuthorsDto } from './delete-authors.dto'; +import { ListAuthorBooksDto } from './list-author-books.dto'; +import { ListAuthorInsightsDto } from './list-author-insights.dto'; +import { ListAuthorMetadataDto } from './list-author-metadata.dto'; +import { ListAuthorsDto } from './list-authors.dto'; +import { ListDuplicateSuggestionsDto } from './list-duplicate-suggestions.dto'; +import { LookupAuthorMetadataDto } from './lookup-author-metadata.dto'; +import { MergeAuthorsDto } from './merge-authors.dto'; +import { PreviewAuthorEnrichmentCountDto } from './preview-author-enrichment-count.dto'; +import { UpdateAuthorDto } from './update-author.dto'; + +async function errorsFor(cls: new () => T, value: Record) { + const dto = plainToInstance(cls, value); + return validate(dto); +} + +describe('Authors DTO validation', () => { + it('validates bounded integer arrays for bulk and delete payloads', async () => { + expect((await errorsFor(BulkAuthorIdsDto, { authorIds: [1, 2, 3] })).length).toBe(0); + expect((await errorsFor(DeleteAuthorsDto, { authorIds: ['1', '2'] })).length).toBe(0); + expect((await errorsFor(BulkAuthorIdsDto, { authorIds: [] })).length).toBeGreaterThan(0); + expect((await errorsFor(BulkAuthorIdsDto, { authorIds: Array.from({ length: 1001 }, () => 1) })).length).toBeGreaterThan(0); + expect((await errorsFor(DeleteAuthorsDto, { authorIds: [0] })).length).toBeGreaterThan(0); + }); + + it('validates merge payload requirements and numeric coercion', async () => { + expect((await errorsFor(MergeAuthorsDto, { targetAuthorId: '5', sourceAuthorIds: ['6', '7'] })).length).toBe(0); + expect((await errorsFor(MergeAuthorsDto, { targetAuthorId: 0, sourceAuthorIds: [1] })).length).toBeGreaterThan(0); + expect((await errorsFor(MergeAuthorsDto, { targetAuthorId: 1, sourceAuthorIds: [] })).length).toBeGreaterThan(0); + }); + + it('transforms and validates list-authors query options', async () => { + const dto = plainToInstance(ListAuthorsDto, { + q: 'dune', + page: '1', + size: '25', + sort: 'bookCount', + order: 'desc', + libraryId: '3', + hasPhoto: 'true', + minBookCount: '2', + }); + + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.page).toBe(1); + expect(dto.size).toBe(25); + expect(dto.libraryId).toBe(3); + expect(dto.hasPhoto).toBe(true); + expect(dto.minBookCount).toBe(2); + + expect((await errorsFor(ListAuthorsDto, { sort: 'invalid' })).length).toBeGreaterThan(0); + expect((await errorsFor(ListAuthorsDto, { order: 'up' })).length).toBeGreaterThan(0); + }); + + it('validates books/insights/suggestion query boundaries', async () => { + expect((await errorsFor(ListAuthorBooksDto, { page: '0', size: '50', sort: 'title', order: 'asc', libraryId: '1' })).length).toBe(0); + expect((await errorsFor(ListAuthorInsightsDto, { libraryId: '1', windowDays: '30', limit: '10' })).length).toBe(0); + expect((await errorsFor(ListDuplicateSuggestionsDto, { libraryId: '1', limit: '50', poolSize: '200', minConfidence: '0.8' })).length).toBe(0); + + expect((await errorsFor(ListAuthorBooksDto, { size: '101' })).length).toBeGreaterThan(0); + expect((await errorsFor(ListAuthorInsightsDto, { windowDays: '366' })).length).toBeGreaterThan(0); + expect((await errorsFor(ListDuplicateSuggestionsDto, { minConfidence: '0.1' })).length).toBeGreaterThan(0); + }); + + it('normalizes metadata provider query values and rejects unknown providers', async () => { + const dto = plainToInstance(ListAuthorMetadataDto, { + q: 'Jane Doe', + providers: 'audnexus, audnexus', + limit: '25', + }); + + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + expect(dto.providers).toEqual(['audnexus', 'audnexus']); + expect(dto.limit).toBe(25); + + const arrayProviders = plainToInstance(ListAuthorMetadataDto, { + q: 'Jane Doe', + providers: ['audnexus, audnexus', 'audnexus'], + }); + const arrayErrors = await validate(arrayProviders); + expect(arrayErrors).toHaveLength(0); + expect(arrayProviders.providers).toEqual(['audnexus', 'audnexus', 'audnexus']); + + const invalidProvidersShape = plainToInstance(ListAuthorMetadataDto, { + q: 'Jane Doe', + providers: 123, + }); + expect((await validate(invalidProvidersShape)).length).toBeGreaterThan(0); + + expect((await errorsFor(ListAuthorMetadataDto, { q: 'Jane', providers: 'invalid' })).length).toBeGreaterThan(0); + }); + + it('validates lookup and update payload constraints', async () => { + expect((await errorsFor(LookupAuthorMetadataDto, { provider: AuthorMetadataProviderKey.AUDNEXUS, id: 'abc' })).length).toBe(0); + expect((await errorsFor(UpdateAuthorDto, { name: 'Updated', sortName: null, description: 'Bio' })).length).toBe(0); + + expect((await errorsFor(LookupAuthorMetadataDto, { provider: 'invalid', id: 'abc' })).length).toBeGreaterThan(0); + expect((await errorsFor(LookupAuthorMetadataDto, { provider: AuthorMetadataProviderKey.AUDNEXUS, id: '' })).length).toBeGreaterThan(0); + expect((await errorsFor(UpdateAuthorDto, { name: 'x'.repeat(501) })).length).toBeGreaterThan(0); + }); + + it('requires enrichment preview conditions and boolean flags', async () => { + expect( + ( + await errorsFor(PreviewAuthorEnrichmentCountDto, { + conditions: { neverEnriched: true, missingBio: false, missingPhoto: true }, + }) + ).length, + ).toBe(0); + + expect((await errorsFor(PreviewAuthorEnrichmentCountDto, {})).length).toBeGreaterThan(0); + expect((await errorsFor(PreviewAuthorEnrichmentCountDto, { conditions: { neverEnriched: 'true' } })).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts b/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts index cf5bb675d..ae4ac2583 100644 --- a/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts +++ b/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts @@ -17,6 +17,18 @@ describe('AuthorMetadataFetchService', () => { service = new AuthorMetadataFetchService(registry as never); }); + it('listProviders maps registry providers for client display', () => { + registry.all.mockReturnValue([ + { key: 'audnexus', label: 'Audnexus', identifiable: true }, + { key: 'other', label: 'Other', identifiable: false }, + ]); + + expect(service.listProviders()).toEqual([ + { key: 'audnexus', label: 'Audnexus', identifiable: true }, + { key: 'other', label: 'Other', identifiable: false }, + ]); + }); + it('quickSearchDetailed returns candidate when provider succeeds', async () => { registry.select.mockReturnValue([ { @@ -71,6 +83,31 @@ describe('AuthorMetadataFetchService', () => { }); }); + it('search returns empty when no providers match the filter', async () => { + registry.select.mockReturnValue([]); + + await expect(service.search({ name: 'Nobody' }, { keys: ['audnexus'] })).resolves.toEqual([]); + }); + + it('search clamps limit and tolerates provider failures by returning successful batches', async () => { + registry.select.mockReturnValue([ + { + key: 'audnexus', + label: 'Audnexus', + identifiable: false, + search: vi.fn().mockResolvedValue([{ provider: 'audnexus', providerId: 'A1', name: 'Author One' }]), + }, + { + key: 'other', + label: 'Other', + identifiable: false, + search: vi.fn().mockRejectedValue(new Error('provider unavailable')), + }, + ]); + + await expect(service.search({ name: 'Author One', limit: 0 })).resolves.toEqual([{ provider: 'audnexus', providerId: 'A1', name: 'Author One' }]); + }); + it('stream enforces global result limit across providers', async () => { registry.select.mockReturnValue([ { @@ -96,4 +133,50 @@ describe('AuthorMetadataFetchService', () => { const emitted = await firstValueFrom(service.stream({ name: 'author', limit: 2 }).pipe(toArray())); expect(emitted).toHaveLength(2); }); + + it('quickSearchDetailed falls back to the best search candidate when identifiable lookup fails', async () => { + const provider = { + key: 'audnexus', + label: 'Audnexus', + identifiable: true, + search: vi.fn().mockResolvedValue([{ provider: 'audnexus', providerId: 'A1', name: 'Author One' }]), + lookupById: vi.fn().mockRejectedValue(new Error('lookup failed')), + }; + registry.select.mockReturnValue([provider]); + + await expect(service.quickSearchDetailed({ name: 'Author One' })).resolves.toEqual({ + candidate: { provider: 'audnexus', providerId: 'A1', name: 'Author One' }, + failure: null, + }); + }); + + it('lookupById returns null when provider is missing or not identifiable', async () => { + registry.find.mockReturnValueOnce(undefined).mockReturnValueOnce({ + key: 'audnexus', + label: 'Audnexus', + identifiable: false, + search: vi.fn(), + }); + + await expect(service.lookupById('audnexus', 'x1')).resolves.toBeNull(); + await expect(service.lookupById('audnexus', 'x2')).resolves.toBeNull(); + }); + + it('lookupById delegates to identifiable provider', async () => { + const provider = { + key: 'audnexus', + label: 'Audnexus', + identifiable: true, + search: vi.fn(), + lookupById: vi.fn().mockResolvedValue({ provider: 'audnexus', providerId: '42', name: 'Douglas Adams' }), + }; + registry.find.mockReturnValue(provider); + + await expect(service.lookupById('audnexus', '42', 'us')).resolves.toEqual({ + provider: 'audnexus', + providerId: '42', + name: 'Douglas Adams', + }); + expect(provider.lookupById).toHaveBeenCalledWith('42', 'us'); + }); }); diff --git a/server/src/modules/authors/metadata/constants.test.ts b/server/src/modules/authors/metadata/constants.test.ts new file mode 100644 index 000000000..d0caf2df3 --- /dev/null +++ b/server/src/modules/authors/metadata/constants.test.ts @@ -0,0 +1,8 @@ +import { AUTHOR_METADATA_PROVIDERS } from './constants'; + +describe('AUTHOR_METADATA_PROVIDERS', () => { + it('is a stable symbol token for provider injection', () => { + expect(typeof AUTHOR_METADATA_PROVIDERS).toBe('symbol'); + expect(AUTHOR_METADATA_PROVIDERS.description).toBe('AUTHOR_METADATA_PROVIDERS'); + }); +}); diff --git a/server/src/modules/authors/metadata/provider-registry.test.ts b/server/src/modules/authors/metadata/provider-registry.test.ts new file mode 100644 index 000000000..e23123a1d --- /dev/null +++ b/server/src/modules/authors/metadata/provider-registry.test.ts @@ -0,0 +1,60 @@ +import { BadRequestException } from '@nestjs/common'; + +import { AuthorMetadataProviderRegistry } from './provider-registry'; + +describe('AuthorMetadataProviderRegistry', () => { + function makeRegistry() { + const providers = [ + { + key: 'audnexus', + label: 'Audnexus', + identifiable: true, + search: vi.fn(), + }, + { + key: 'custom', + label: 'Custom', + identifiable: false, + search: vi.fn(), + }, + ]; + return { + providers, + registry: new AuthorMetadataProviderRegistry(providers as never), + }; + } + + it('returns all registered providers by default', () => { + const { registry, providers } = makeRegistry(); + + expect(registry.all()).toEqual(providers); + expect(registry.select()).toEqual(providers); + }); + + it('returns empty list when no keys are requested', () => { + const { registry } = makeRegistry(); + expect(registry.select([])).toEqual([]); + }); + + it('selects only requested providers in registration order', () => { + const { registry } = makeRegistry(); + + expect(registry.select(['custom', 'audnexus'] as never)).toEqual([ + expect.objectContaining({ key: 'audnexus' }), + expect.objectContaining({ key: 'custom' }), + ]); + }); + + it('throws BadRequestException for unknown provider keys', () => { + const { registry } = makeRegistry(); + + expect(() => registry.select(['missing'] as never)).toThrow(BadRequestException); + }); + + it('find returns provider by key or undefined', () => { + const { registry } = makeRegistry(); + + expect(registry.find('audnexus' as never)).toEqual(expect.objectContaining({ key: 'audnexus' })); + expect(registry.find('missing' as never)).toBeUndefined(); + }); +}); diff --git a/server/src/modules/authors/metadata/providers/author-metadata-provider.test.ts b/server/src/modules/authors/metadata/providers/author-metadata-provider.test.ts new file mode 100644 index 000000000..b03d5f295 --- /dev/null +++ b/server/src/modules/authors/metadata/providers/author-metadata-provider.test.ts @@ -0,0 +1,40 @@ +import { AuthorMetadataProviderError, isIdentifiableAuthorProvider } from './author-metadata-provider'; + +describe('author metadata provider helpers', () => { + it('detects identifiable providers via discriminant field', () => { + const identifiable = { + key: 'audnexus', + label: 'Audnexus', + identifiable: true, + search: vi.fn(), + lookupById: vi.fn(), + }; + const nonIdentifiable = { + key: 'custom', + label: 'Custom', + identifiable: false, + search: vi.fn(), + }; + + expect(isIdentifiableAuthorProvider(identifiable as never)).toBe(true); + expect(isIdentifiableAuthorProvider(nonIdentifiable as never)).toBe(false); + }); + + it('stores provider error metadata with sane defaults', () => { + const withOptions = new AuthorMetadataProviderError('rate limited', { + httpStatus: 429, + retryAfterMs: 5000, + transient: true, + }); + const withDefaults = new AuthorMetadataProviderError('unexpected response'); + + expect(withOptions.name).toBe('AuthorMetadataProviderError'); + expect(withOptions.httpStatus).toBe(429); + expect(withOptions.retryAfterMs).toBe(5000); + expect(withOptions.transient).toBe(true); + + expect(withDefaults.httpStatus).toBeNull(); + expect(withDefaults.retryAfterMs).toBeNull(); + expect(withDefaults.transient).toBe(false); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket-events.service.test.ts b/server/src/modules/book-bucket/book-bucket-events.service.test.ts new file mode 100644 index 000000000..f01ed7c5c --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket-events.service.test.ts @@ -0,0 +1,22 @@ +import { EventEmitter } from 'events'; + +import { BOOK_BUCKET_FILE_INGESTED, BookBucketEventsService } from './book-bucket-events.service'; + +describe('BookBucketEventsService', () => { + it('is an EventEmitter and exposes the ingestion event name', () => { + const events = new BookBucketEventsService(); + + expect(events).toBeInstanceOf(EventEmitter); + expect(BOOK_BUCKET_FILE_INGESTED).toBe('book-bucket.file.ingested'); + }); + + it('emits and receives book-bucket ingestion events', () => { + const events = new BookBucketEventsService(); + const listener = vi.fn(); + events.on(BOOK_BUCKET_FILE_INGESTED, listener); + + events.emit(BOOK_BUCKET_FILE_INGESTED, 42); + + expect(listener).toHaveBeenCalledWith(42); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket-finalize.service.test.ts b/server/src/modules/book-bucket/book-bucket-finalize.service.test.ts index f6b28fcf7..67e3ba69e 100644 --- a/server/src/modules/book-bucket/book-bucket-finalize.service.test.ts +++ b/server/src/modules/book-bucket/book-bucket-finalize.service.test.ts @@ -1,13 +1,55 @@ +vi.mock('fs/promises', () => ({ + access: vi.fn(), + readFile: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), +})); + +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { access, readFile, stat, unlink } from 'fs/promises'; + import type { BookBucketMetadata } from '@projectx/types'; import { BookBucketFinalizeService } from './book-bucket-finalize.service'; +const mockAccess = vi.mocked(access); +const mockReadFile = vi.mocked(readFile); +const mockStat = vi.mocked(stat); +const mockUnlink = vi.mocked(unlink); + function makeService() { + const db = { + select: vi.fn(), + update: vi.fn(), + }; const repo = { findById: vi.fn(), countsByStatus: vi.fn(), + findByIds: vi.fn(), + findSelectionBatch: vi.fn(), + findAllIds: vi.fn(), + deleteById: vi.fn(), + }; + const libraryService = { + verifyUserAccess: vi.fn().mockResolvedValue(undefined), }; const appSettings = { getAutoFinalizeSettings: vi.fn(), + getUploadPattern: vi.fn().mockResolvedValue(null), + }; + const metadataService = { + downloadAndSaveCover: vi.fn().mockResolvedValue(false), + saveExtractedCoverBytes: vi.fn().mockResolvedValue(undefined), + replaceAuthors: vi.fn().mockResolvedValue(undefined), + replaceGenres: vi.fn().mockResolvedValue(undefined), + }; + const validator = { + validateFormat: vi.fn(), + }; + const storage = { + moveToPath: vi.fn().mockResolvedValue(undefined), + }; + const processor = { + createBookRecord: vi.fn().mockResolvedValue({ bookId: 101 }), }; const events = { on: vi.fn(), @@ -17,19 +59,19 @@ function makeService() { }; const service = new BookBucketFinalizeService( - {} as never, + db as never, repo as never, - {} as never, + libraryService as never, appSettings as never, - {} as never, - {} as never, - {} as never, - {} as never, + metadataService as never, + validator as never, + storage as never, + processor as never, events as never, gateway as never, ); - return { service, repo, appSettings }; + return { service, db, repo, libraryService, appSettings, metadataService, validator, storage, processor, events, gateway }; } function makeRow(overrides?: Partial>) { @@ -59,6 +101,10 @@ function makeRow(overrides?: Partial>) { describe('BookBucketFinalizeService', () => { beforeEach(() => { vi.clearAllMocks(); + mockAccess.mockRejectedValue(new Error('ENOENT')); + mockReadFile.mockResolvedValue(Buffer.from('cover-bytes')); + mockStat.mockResolvedValue({ size: 100 } as never); + mockUnlink.mockResolvedValue(undefined); }); describe('triggerAutoFinalize', () => { @@ -245,4 +291,406 @@ describe('BookBucketFinalizeService', () => { expect(finalizeSpy).not.toHaveBeenCalled(); }); }); + + describe('finalize', () => { + it('returns missing-row failures for explicit ids not found in repository', async () => { + const { service, repo } = makeService(); + const rowOne = makeRow({ id: 1 }); + repo.findByIds.mockResolvedValue([rowOne]); + vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never); + vi.spyOn(service as never, 'finalizeFile').mockResolvedValueOnce({ + fileId: 1, + fileName: 'book.epub', + success: true, + bookId: 101, + } as never); + vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never); + + const result = await service.finalize(7, true, [1, 2], false, [], 5, 9); + + expect(result.total).toBe(2); + expect(result.succeeded).toBe(1); + expect(result.failed).toBe(1); + expect(result.results[1]).toEqual( + expect.objectContaining({ + fileId: 2, + success: false, + message: 'Book Bucket file not found', + }), + ); + }); + + it('iterates selectAll batches until no rows remain', async () => { + const { service, repo } = makeService(); + repo.findSelectionBatch.mockResolvedValueOnce([makeRow({ id: 1 }), makeRow({ id: 2 })]).mockResolvedValueOnce([]); + vi.spyOn(service as never, 'buildDuplicateLookup').mockResolvedValue(new Map() as never); + vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({ + fileId: 1, + fileName: 'book.epub', + success: true, + bookId: 10, + } as never); + vi.spyOn(service as never, 'emitSummary').mockResolvedValue(undefined as never); + + const result = await service.finalize(7, true, [], true, [], 5, 9, [], 'ready', 'foo'); + + expect(repo.findSelectionBatch).toHaveBeenCalledTimes(2); + expect(result.total).toBe(2); + expect(result.failed).toBe(0); + }); + }); + + describe('finalizeFile', () => { + it('fails early when destination library or folder is missing', async () => { + const { service } = makeService(); + + await expect((service as any).finalizeFile(makeRow(), undefined, undefined, new Map(), 1, true)).resolves.toEqual({ + fileId: 1, + fileName: 'book.epub', + success: false, + message: 'Destination is not set for this file', + }); + }); + + it('fails when target file already exists at resolved destination', async () => { + const { service, validator } = makeService(); + vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); + vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); + vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/existing.epub' as never); + mockAccess.mockResolvedValueOnce(undefined as never); + + const result = await (service as any).finalizeFile( + makeRow({ targetLibraryId: 5, targetFolderId: 9 }), + undefined, + undefined, + new Map(), + 1, + true, + ); + + expect(result).toEqual({ + fileId: 1, + fileName: 'book.epub', + success: false, + message: 'A file with this name already exists at the target location', + }); + expect(validator.validateFormat).toHaveBeenCalled(); + }); + + it('marks rows as duplicates when duplicate lookup finds an existing book', async () => { + const { service } = makeService(); + vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); + vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); + vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new.epub' as never); + mockAccess.mockRejectedValueOnce(new Error('missing')); + vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(77 as never); + + await expect( + (service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true), + ).resolves.toEqual( + expect.objectContaining({ + success: false, + isDuplicate: true, + existingBookId: 77, + }), + ); + }); + + it('rolls back moved files and reports failure when book record creation fails', async () => { + const { service, storage, processor } = makeService(); + vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); + vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); + vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new/book.epub' as never); + vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(null as never); + mockAccess.mockRejectedValueOnce(new Error('missing')); + mockStat.mockResolvedValueOnce({ size: 321 } as never); + processor.createBookRecord.mockRejectedValueOnce(new Error('create failed')); + + await expect( + (service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true), + ).resolves.toEqual( + expect.objectContaining({ + success: false, + message: 'create failed', + }), + ); + expect(storage.moveToPath).toHaveBeenNthCalledWith(1, '/tmp/book.epub', '/library/new/book.epub'); + expect(storage.moveToPath).toHaveBeenNthCalledWith(2, '/library/new/book.epub', '/tmp/book.epub'); + }); + + it('returns success with relative newName when finalize flow completes', async () => { + const { service, processor } = makeService(); + vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); + vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); + vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new/book.epub' as never); + vi.spyOn(service as never, 'findDuplicate').mockResolvedValue(null as never); + vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); + vi.spyOn(service as never, 'cleanupBookBucketRecord').mockResolvedValue(undefined as never); + mockAccess.mockRejectedValueOnce(new Error('missing')); + mockStat.mockResolvedValueOnce({ size: 100 } as never); + processor.createBookRecord.mockResolvedValueOnce({ bookId: 555 }); + + await expect( + (service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true), + ).resolves.toEqual({ + fileId: 1, + fileName: 'book.epub', + newName: 'new/book.epub', + success: true, + bookId: 555, + }); + }); + }); + + it('previewNames returns [] for empty explicit selection', async () => { + const { service } = makeService(); + + await expect(service.previewNames([], false, [], 5)).resolves.toEqual([]); + }); + + it('previewNames uses selectAll ids and preserves original filename when no pattern resolves', async () => { + const { service, repo, appSettings, db } = makeService(); + repo.findAllIds.mockResolvedValue([1]); + repo.findByIds.mockResolvedValue([makeRow({ id: 1 })]); + appSettings.getUploadPattern.mockResolvedValue(null); + db.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); + + await expect(service.previewNames([], true, [], undefined, 'ready', 'title')).resolves.toEqual([ + { fileId: 1, fileName: 'book.epub', newName: 'book.epub' }, + ]); + }); + + it('applyMetadata updates scalar metadata fields and related author/genre rows', async () => { + const { service, db, metadataService } = makeService(); + const updateChain = { + set: vi.fn(), + where: vi.fn().mockResolvedValue(undefined), + }; + updateChain.set.mockReturnValue(updateChain); + db.update.mockReturnValue(updateChain); + const bucketRow = makeRow({ + selectedMetadata: { + title: 'New Title', + subtitle: 'Sub', + description: 'Desc', + isbn13: '9780306406157', + publisher: 'Pub', + publishedYear: 2022, + language: 'en-US', + pageCount: 300, + seriesName: 'Saga', + seriesIndex: 2.5, + authors: ['Author A'], + genres: ['Fantasy'], + } as BookBucketMetadata, + coverPath: null, + }); + + await (service as any).applyMetadata(15, bucketRow); + + expect(updateChain.set).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'New Title', + subtitle: 'Sub', + description: 'Desc', + isbn13: '9780306406157', + publisher: 'Pub', + publishedYear: 2022, + language: 'en-US', + pageCount: 300, + seriesName: 'Saga', + seriesIndex: 2.5, + }), + ); + expect(metadataService.replaceAuthors).toHaveBeenCalledWith(15, [{ name: 'Author A', sortName: null }]); + expect(metadataService.replaceGenres).toHaveBeenCalledWith(15, ['Fantasy']); + }); + + it('applyMetadata prefers selected coverUrl and skips extracted cover copy when download succeeds', async () => { + const { service, db, metadataService } = makeService(); + metadataService.downloadAndSaveCover.mockResolvedValueOnce(true); + const updateChain = { + set: vi.fn(), + where: vi.fn().mockResolvedValue(undefined), + }; + updateChain.set.mockReturnValue(updateChain); + db.update.mockReturnValue(updateChain); + mockReadFile.mockResolvedValue(Buffer.from('cover-bytes')); + + await (service as any).applyMetadata( + 20, + makeRow({ + coverPath: '/tmp/cover.jpg', + selectedMetadata: { title: 'T', coverUrl: 'https://covers.example/1.jpg' } as BookBucketMetadata, + }), + ); + + expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith('https://covers.example/1.jpg', 20); + expect(metadataService.saveExtractedCoverBytes).not.toHaveBeenCalled(); + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it('applyMetadata falls back to extracted cover bytes when cover download is unavailable', async () => { + const { service, db, metadataService } = makeService(); + metadataService.downloadAndSaveCover.mockResolvedValueOnce(false); + const updateChain = { + set: vi.fn(), + where: vi.fn().mockResolvedValue(undefined), + }; + updateChain.set.mockReturnValue(updateChain); + db.update.mockReturnValue(updateChain); + mockReadFile.mockResolvedValueOnce(Buffer.from('cover-bytes')); + + await (service as any).applyMetadata( + 21, + makeRow({ + coverPath: '/tmp/cover.jpg', + selectedMetadata: { title: 'T', coverUrl: 'https://covers.example/1.jpg' } as BookBucketMetadata, + }), + ); + + expect(metadataService.saveExtractedCoverBytes).toHaveBeenCalledWith(21, Buffer.from('cover-bytes')); + }); + + it('cleanupBookBucketRecord deletes cover files and bucket row id', async () => { + const { service, repo } = makeService(); + mockUnlink.mockResolvedValue(undefined); + + await (service as any).cleanupBookBucketRecord(makeRow({ id: 44, coverPath: '/tmp/cover.png' })); + + expect(mockUnlink).toHaveBeenCalledWith('/tmp/cover.png'); + expect(mockUnlink).toHaveBeenCalledWith('/tmp/cover_thumb.jpg'); + expect(repo.deleteById).toHaveBeenCalledWith(44); + }); + + it('findLibraryOrFail and findFolderOrFail throw typed errors for invalid destination records', async () => { + const { service, db } = makeService(); + const selectChain = { + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + }; + selectChain.from.mockReturnValue(selectChain); + selectChain.where.mockReturnValue(selectChain); + selectChain.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 9, libraryId: 4, path: '/x' }]); + db.select.mockReturnValue(selectChain); + + await expect((service as any).findLibraryOrFail(2)).rejects.toBeInstanceOf(NotFoundException); + await expect((service as any).findFolderOrFail(9, 5)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('buildDuplicateLookup collects isbn and title keys per library', async () => { + const { service, db } = makeService(); + const selectChain = { + from: vi.fn(), + innerJoin: vi.fn(), + where: vi.fn(), + }; + selectChain.from.mockReturnValue(selectChain); + selectChain.innerJoin.mockReturnValue(selectChain); + selectChain.where + .mockResolvedValueOnce([{ bookId: 11, isbn13: '9780306406157' }]) + .mockResolvedValueOnce([{ bookId: 12, isbn10: '0306406152' }]) + .mockResolvedValueOnce([{ bookId: 13, normalizedTitle: 'dune' }]); + db.select.mockReturnValue(selectChain); + + const lookup = await (service as any).buildDuplicateLookup( + [ + makeRow({ id: 1, targetLibraryId: 5, selectedMetadata: { isbn13: '9780306406157' } }), + makeRow({ id: 2, targetLibraryId: 5, selectedMetadata: { isbn10: '0306406152' } }), + makeRow({ id: 3, targetLibraryId: 5, selectedMetadata: { title: 'Dune' } }), + ], + undefined, + new Map(), + ); + + expect(lookup.get('library:5|isbn13:9780306406157')).toBe(11); + expect(lookup.get('library:5|isbn10:0306406152')).toBe(12); + expect(lookup.get('library:5|title:dune')).toBe(13); + }); + + it('findDuplicate queries by isbn and title when prebuilt lookup misses', async () => { + const { service, db } = makeService(); + const selectChain = { + from: vi.fn(), + innerJoin: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + }; + selectChain.from.mockReturnValue(selectChain); + selectChain.innerJoin.mockReturnValue(selectChain); + selectChain.where.mockReturnValue(selectChain); + selectChain.limit + .mockResolvedValueOnce([{ bookId: 91 }]) + .mockResolvedValueOnce([{ bookId: 92 }]) + .mockResolvedValueOnce([]); + db.select.mockReturnValue(selectChain); + + await expect((service as any).findDuplicate(4, { isbn13: '9780306406157', isbn10: null, title: null })).resolves.toBe(91); + await expect((service as any).findDuplicate(4, { isbn13: null, isbn10: null, title: 'Dune' })).resolves.toBe(92); + await expect((service as any).findDuplicate(4, { isbn13: null, isbn10: null, title: null })).resolves.toBeNull(); + }); + + it('resolveDestination builds names from patterns and falls back to original filename', async () => { + const { service, appSettings } = makeService(); + appSettings.getUploadPattern.mockResolvedValue(null); + const rowWithMeta = makeRow({ + fileName: 'original.epub', + selectedMetadata: { title: 'Dune', seriesIndex: 2.5 } as BookBucketMetadata, + }); + + await expect((service as any).resolveDestination({ fileNamingPattern: '{title}-{seriesIndex}' }, '/library', rowWithMeta, 'epub')).resolves.toBe( + '/library/Dune-02.5.epub', + ); + await expect((service as any).resolveDestination({ fileNamingPattern: null }, '/library', rowWithMeta, 'epub')).resolves.toBe( + '/library/original.epub', + ); + }); + + it('findDuplicate resolves from prebuilt lookup before querying database', async () => { + const { service } = makeService(); + const duplicateLookup = new Map([['library:4|isbn13:9780306406157', 88]]); + + await expect((service as any).findDuplicate(4, { isbn13: '9780306406157', isbn10: null, title: null }, duplicateLookup)).resolves.toBe(88); + }); + + it('triggerAutoFinalize skips when auto-finalize is disabled or destination is incomplete', async () => { + const { service, appSettings, repo } = makeService(); + appSettings.getAutoFinalizeSettings.mockResolvedValueOnce({ + enabled: false, + threshold: 80, + libraryId: 1, + folderId: 2, + metadataMode: 'safe_merge', + }); + appSettings.getAutoFinalizeSettings.mockResolvedValueOnce({ + enabled: true, + threshold: 80, + libraryId: null, + folderId: 2, + metadataMode: 'safe_merge', + }); + + await service.triggerAutoFinalize(1); + await service.triggerAutoFinalize(1); + + expect(repo.findById).not.toHaveBeenCalled(); + }); + + it('onModuleInit subscribes to ingestion events and triggers auto-finalize callback', async () => { + const { service, events } = makeService(); + const triggerSpy = vi.spyOn(service, 'triggerAutoFinalize').mockResolvedValue(undefined); + + service.onModuleInit(); + const handler = events.on.mock.calls[0]?.[1] as ((fileId: number) => void) | undefined; + expect(handler).toBeDefined(); + handler?.(77); + await Promise.resolve(); + + expect(triggerSpy).toHaveBeenCalledWith(77); + }); }); diff --git a/server/src/modules/book-bucket/book-bucket-ingest.service.test.ts b/server/src/modules/book-bucket/book-bucket-ingest.service.test.ts index c14cd05b6..6d87fd4a4 100644 --- a/server/src/modules/book-bucket/book-bucket-ingest.service.test.ts +++ b/server/src/modules/book-bucket/book-bucket-ingest.service.test.ts @@ -66,7 +66,7 @@ function makeService() { gateway as never, ); - return { service, repo, validator, storage }; + return { service, repo, validator, storage, metadataService, events, appSettings, metadataFetchPipeline, gateway }; } describe('BookBucketIngestService', () => { @@ -219,4 +219,193 @@ describe('BookBucketIngestService', () => { expect(Logger.prototype.warn).toHaveBeenCalledWith(expect.stringContaining('parse failed')); }); }); + + describe('retryFetch', () => { + it('ignores missing, non-error, or formatless rows', async () => { + const { service, repo } = makeService(); + repo.findById.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 1, status: 'ready', format: 'epub' }).mockResolvedValueOnce({ + id: 2, + status: 'error', + format: null, + }); + + await service.retryFetch(1); + await service.retryFetch(1); + await service.retryFetch(2); + + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('requeues error rows and restarts extraction pipeline', async () => { + const { service, repo } = makeService(); + repo.findById.mockResolvedValue({ id: 3, status: 'error', format: 'epub', absolutePath: '/bucket/3.epub' }); + const extractSpy = vi.spyOn(service as any, 'extractMetadataAsync').mockImplementation(() => undefined); + + await service.retryFetch(3); + + expect(repo.update).toHaveBeenCalledWith(3, { status: 'pending', errorMessage: null }); + expect(extractSpy).toHaveBeenCalledWith(3, '/bucket/3.epub', 'epub'); + }); + }); + + describe('autoFetchMetadataAsync', () => { + it('returns early when auto-fetch is disabled or no searchable metadata is available', async () => { + const { service, appSettings, repo } = makeService(); + appSettings.isBookBucketAutoFetchEnabled.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + repo.findById.mockResolvedValue({ + id: 8, + status: 'ready', + embeddedMetadata: { authors: ['No title and no isbn'] }, + }); + + await (service as any).autoFetchMetadataAsync(8); + await (service as any).autoFetchMetadataAsync(8); + + expect(repo.update).not.toHaveBeenCalledWith(8, { status: 'fetching' }); + }); + + it('updates fetched metadata and confidence after pipeline resolution', async () => { + const { service, appSettings, repo, metadataFetchPipeline } = makeService(); + appSettings.isBookBucketAutoFetchEnabled.mockResolvedValue(true); + repo.findById.mockResolvedValue({ + id: 9, + status: 'ready', + embeddedMetadata: { title: 'Dune', isbn13: '9780441172719', authors: ['Frank Herbert'] }, + }); + (metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({ + resolved: { + title: 'Dune', + isbn13: '9780441172719', + authors: ['Frank Herbert'], + }, + sources: { title: { provider: 'google' } }, + }); + + await (service as any).autoFetchMetadataAsync(9); + + expect(repo.update).toHaveBeenNthCalledWith(1, 9, { status: 'fetching' }); + expect(repo.update).toHaveBeenNthCalledWith( + 2, + 9, + expect.objectContaining({ + status: 'ready', + confidence: 95, + fetchedMetadata: expect.objectContaining({ title: 'Dune', isbn13: '9780441172719' }), + }), + ); + }); + + it('falls back to ready status when metadata fetch pipeline throws', async () => { + const { service, appSettings, repo, metadataFetchPipeline } = makeService(); + appSettings.isBookBucketAutoFetchEnabled.mockResolvedValue(true); + repo.findById.mockResolvedValue({ + id: 10, + status: 'ready', + embeddedMetadata: { title: 'Dune' }, + }); + (metadataFetchPipeline as any).runWithSources = vi.fn().mockRejectedValue(new Error('provider timeout')); + + await (service as any).autoFetchMetadataAsync(10); + + expect(repo.update).toHaveBeenLastCalledWith(10, { status: 'ready' }); + }); + + it('computes low confidence for conflicting ISBN values', async () => { + const { service, appSettings, repo, metadataFetchPipeline } = makeService(); + appSettings.isBookBucketAutoFetchEnabled.mockResolvedValue(true); + repo.findById.mockResolvedValue({ + id: 11, + status: 'ready', + embeddedMetadata: { title: 'Dune', isbn13: '9780441172719', authors: ['Frank Herbert'] }, + }); + (metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({ + resolved: { + title: 'Dune', + isbn13: '9780312890173', + authors: ['Frank Herbert'], + }, + sources: {}, + }); + + await (service as any).autoFetchMetadataAsync(11); + + expect(repo.update).toHaveBeenLastCalledWith( + 11, + expect.objectContaining({ + status: 'ready', + confidence: 10, + }), + ); + }); + + it('computes high confidence when title, author, year, and series align', async () => { + const { service, appSettings, repo, metadataFetchPipeline } = makeService(); + appSettings.isBookBucketAutoFetchEnabled.mockResolvedValue(true); + repo.findById.mockResolvedValue({ + id: 12, + status: 'ready', + embeddedMetadata: { + title: 'Dune: Deluxe Edition', + authors: ['Herbert, Frank'], + publishedYear: 1965, + seriesName: 'Dune Saga', + }, + }); + (metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({ + resolved: { + title: 'Dune', + authors: ['Frank Herbert'], + publishedYear: 1965, + seriesName: 'Dune Saga', + }, + sources: {}, + }); + + await (service as any).autoFetchMetadataAsync(12); + + expect(repo.update).toHaveBeenLastCalledWith( + 12, + expect.objectContaining({ + status: 'ready', + confidence: 100, + }), + ); + }); + + it('marks ready without fetchedMetadata when providers return only undefined fields', async () => { + const { service, appSettings, repo, metadataFetchPipeline } = makeService(); + appSettings.isBookBucketAutoFetchEnabled.mockResolvedValue(true); + repo.findById.mockResolvedValue({ + id: 13, + status: 'ready', + embeddedMetadata: { title: 'Known Title' }, + }); + (metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({ + resolved: { + title: undefined, + authors: undefined, + }, + sources: {}, + }); + + await (service as any).autoFetchMetadataAsync(13); + + expect(repo.update).toHaveBeenNthCalledWith(1, 13, { status: 'fetching' }); + expect(repo.update).toHaveBeenNthCalledWith(2, 13, { status: 'ready' }); + }); + }); + + it('extractMetadataAsync emits summary and ingestion events after successful extraction chain', async () => { + const { service, metadataService, events } = makeService(); + const autoFetchSpy = vi.spyOn(service as any, 'autoFetchMetadataAsync').mockResolvedValue(undefined); + const emitSummarySpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined); + + (service as any).extractMetadataAsync(12, '/bucket/12.epub', 'epub'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(metadataService.extractAndSave).toHaveBeenCalledWith(12, '/bucket/12.epub', 'epub', '/books/book-bucket/covers'); + expect(autoFetchSpy).toHaveBeenCalledWith(12); + expect(emitSummarySpy).toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith('book-bucket.file.ingested', 12); + }); }); diff --git a/server/src/modules/book-bucket/book-bucket-metadata.service.test.ts b/server/src/modules/book-bucket/book-bucket-metadata.service.test.ts index 2a3bb020e..a491f18e8 100644 --- a/server/src/modules/book-bucket/book-bucket-metadata.service.test.ts +++ b/server/src/modules/book-bucket/book-bucket-metadata.service.test.ts @@ -7,15 +7,38 @@ vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn(), })); +vi.mock('../metadata/lib/epub', () => ({ + extractEpubMetadata: vi.fn(), +})); + +vi.mock('../metadata/lib/mobi-parser', () => ({ + parseMobiFile: vi.fn(), +})); + +vi.mock('../metadata/lib/cbz-metadata', () => ({ + extractCbzMetadata: vi.fn(), + extractCbrMetadata: vi.fn(), + extractCb7Metadata: vi.fn(), +})); + +vi.mock('../metadata/lib/fb2-parser', () => ({ + parseFb2File: vi.fn(), +})); + vi.mock('../metadata/lib/cover', () => ({ extractCover: vi.fn(), generateThumbnail: vi.fn(), imageExt: vi.fn(), })); +import { Logger } from '@nestjs/common'; import { mkdir, writeFile } from 'fs/promises'; import type { MockedFunction } from 'vitest'; +import { extractEpubMetadata } from '../metadata/lib/epub'; +import { parseMobiFile } from '../metadata/lib/mobi-parser'; +import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from '../metadata/lib/cbz-metadata'; +import { parseFb2File } from '../metadata/lib/fb2-parser'; import { extractCover, generateThumbnail, imageExt } from '../metadata/lib/cover'; import { parsePdfFile } from '../metadata/lib/pdf-parser'; import { BookBucketMetadataService } from './book-bucket-metadata.service'; @@ -26,6 +49,12 @@ const mockExtractCover = extractCover as MockedFunction; const mockGenerateThumbnail = generateThumbnail as MockedFunction; const mockImageExt = imageExt as MockedFunction; const mockParsePdfFile = parsePdfFile as MockedFunction; +const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; +const mockParseMobiFile = parseMobiFile as MockedFunction; +const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; +const mockExtractCbrMetadata = extractCbrMetadata as MockedFunction; +const mockExtractCb7Metadata = extractCb7Metadata as MockedFunction; +const mockParseFb2File = parseFb2File as MockedFunction; describe('BookBucketMetadataService', () => { const repo = { @@ -41,6 +70,12 @@ describe('BookBucketMetadataService', () => { mockGenerateThumbnail.mockResolvedValue(Buffer.from('thumb')); mockImageExt.mockReturnValue('png'); mockParsePdfFile.mockResolvedValue(null); + mockExtractEpubMetadata.mockResolvedValue(null); + mockParseMobiFile.mockResolvedValue(null); + mockExtractCbzMetadata.mockResolvedValue(null); + mockExtractCbrMetadata.mockResolvedValue(null); + mockExtractCb7Metadata.mockResolvedValue(null); + mockParseFb2File.mockResolvedValue(null); }); it('extractAndSave(pdf) reuses a single parse result for metadata and cover persistence', async () => { @@ -142,4 +177,193 @@ describe('BookBucketMetadataService', () => { }); expect(mockWriteFile).not.toHaveBeenCalled(); }); + + it('extractAndSave(non-pdf) falls back to generic extraction path and keeps cover null when not found', async () => { + const service = new BookBucketMetadataService(repo as never); + + await service.extractAndSave(7, '/tmp/book.bin', 'unknown', '/tmp/covers'); + + expect(mockExtractCover).toHaveBeenCalledWith('/tmp/book.bin', 'unknown'); + expect(repo.update).toHaveBeenNthCalledWith(2, 7, { + embeddedMetadata: {}, + coverPath: null, + status: 'ready', + }); + }); + + it('extractAndSave writes error status when metadata extraction fails', async () => { + mockParsePdfFile.mockRejectedValueOnce(new Error('parse failure')); + const service = new BookBucketMetadataService(repo as never); + + await service.extractAndSave(8, '/tmp/broken.pdf', 'pdf', '/tmp/covers'); + + expect(repo.update).toHaveBeenNthCalledWith(2, 8, { + status: 'error', + errorMessage: 'parse failure', + }); + }); + + it('logPdfParseWarning emits distinct messages for large-buffer and parser-warning scenarios', () => { + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const service = new BookBucketMetadataService(repo as never); + + (service as any).logPdfParseWarning({ + code: 'buffered-large-pdf', + absolutePath: '/tmp/large.pdf', + sizeBytes: 5000, + thresholdBytes: 4000, + }); + (service as any).logPdfParseWarning({ + code: 'xmp_parse_failed', + absolutePath: '/tmp/bad.pdf', + errorClass: 'Error', + errorMessage: 'invalid xmp', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('buffered-large-pdf')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('xmp_parse_failed')); + }); + + it('extractMetadata maps epub parser output with non-empty authors and tags', async () => { + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Epub Title', + subtitle: 'Epub Sub', + description: 'Desc', + publisher: 'Pub', + publishedYear: 2001, + language: 'en', + isbn10: '123456789X', + isbn13: '9780306406157', + seriesName: 'Series', + seriesIndex: 1.5, + authors: [{ name: 'Author A', sortName: null }], + tags: ['Sci-Fi'], + } as never); + const service = new BookBucketMetadataService(repo as never); + + await expect((service as any).extractMetadata('/tmp/book.epub', 'epub')).resolves.toEqual({ + title: 'Epub Title', + subtitle: 'Epub Sub', + description: 'Desc', + publisher: 'Pub', + publishedYear: 2001, + language: 'en', + isbn10: '123456789X', + isbn13: '9780306406157', + seriesName: 'Series', + seriesIndex: 1.5, + authors: ['Author A'], + genres: ['Sci-Fi'], + }); + }); + + it('extractMetadata handles mobi-family formats and parses year prefix when valid', async () => { + mockParseMobiFile.mockResolvedValue({ + title: 'Mobi Title', + description: 'Mobi Desc', + publisher: 'Mobi Pub', + publishedDate: '1999-01-01', + language: 'en', + isbn: '9780306406157', + authors: ['Author A'], + tags: ['Tag 1'], + } as never); + const service = new BookBucketMetadataService(repo as never); + + await expect((service as any).extractMetadata('/tmp/book.azw3', 'azw3')).resolves.toEqual({ + title: 'Mobi Title', + description: 'Mobi Desc', + publisher: 'Mobi Pub', + publishedYear: 1999, + language: 'en', + isbn13: '9780306406157', + authors: ['Author A'], + genres: ['Tag 1'], + }); + await expect((service as any).extractMetadata('/tmp/book.azw', 'azw')).resolves.toEqual( + expect.objectContaining({ + title: 'Mobi Title', + }), + ); + }); + + it('extractMetadata maps cbz/cbr/cb7 and fb2 parser outputs', async () => { + mockExtractCbzMetadata.mockResolvedValue({ + title: 'Comic Z', + description: null, + publisher: 'Pub', + publishedYear: 2020, + language: 'en', + seriesName: 'Series', + seriesIndex: 1, + authors: [{ name: 'Artist Z', sortName: null }], + tags: ['Action'], + } as never); + mockExtractCbrMetadata.mockResolvedValue({ + title: 'Comic R', + description: 'Desc', + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [], + tags: [], + } as never); + mockExtractCb7Metadata.mockResolvedValue({ + title: 'Comic 7', + description: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [{ name: 'Artist 7', sortName: null }], + tags: ['Tag7'], + } as never); + mockParseFb2File.mockResolvedValue({ + title: 'FB2 Book', + description: 'FB2 Desc', + publishedYear: 1990, + language: 'ru', + seriesName: 'FB2 Series', + seriesIndex: 2, + authors: [{ name: 'FB2 Author', sortName: null }], + genres: ['Drama'], + } as never); + const service = new BookBucketMetadataService(repo as never); + + await expect((service as any).extractMetadata('/tmp/comic.cbz', 'cbz')).resolves.toEqual( + expect.objectContaining({ + title: 'Comic Z', + authors: ['Artist Z'], + genres: ['Action'], + }), + ); + await expect((service as any).extractMetadata('/tmp/comic.cbr', 'cbr')).resolves.toEqual({ + title: 'Comic R', + description: 'Desc', + publisher: undefined, + publishedYear: undefined, + language: undefined, + seriesName: undefined, + seriesIndex: undefined, + authors: undefined, + genres: undefined, + }); + await expect((service as any).extractMetadata('/tmp/comic.cb7', 'cb7')).resolves.toEqual( + expect.objectContaining({ + title: 'Comic 7', + authors: ['Artist 7'], + genres: ['Tag7'], + }), + ); + await expect((service as any).extractMetadata('/tmp/book.fb2', 'fb2')).resolves.toEqual( + expect.objectContaining({ + title: 'FB2 Book', + authors: ['FB2 Author'], + genres: ['Drama'], + }), + ); + }); }); diff --git a/server/src/modules/book-bucket/book-bucket-watcher.service.test.ts b/server/src/modules/book-bucket/book-bucket-watcher.service.test.ts new file mode 100644 index 000000000..0808d364d --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket-watcher.service.test.ts @@ -0,0 +1,143 @@ +vi.mock('../scanner/lib/classify', () => ({ + isPrimaryFormat: vi.fn(), +})); + +vi.mock('../scanner/lib/stability', () => ({ + waitForStability: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + mkdir: vi.fn(), + readdir: vi.fn(), + unlink: vi.fn(), +})); + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn(), +})); + +import { mkdir, readdir, unlink } from 'fs/promises'; +import { subscribe } from '@parcel/watcher'; + +import { isPrimaryFormat } from '../scanner/lib/classify'; +import { waitForStability } from '../scanner/lib/stability'; +import { BookBucketWatcherService } from './book-bucket-watcher.service'; + +function makeService() { + const config = { + get: vi.fn().mockReturnValue('/data'), + }; + const ingestService = { + ingestFromWatchedFolder: vi.fn(), + }; + const repo = { + findByAbsolutePath: vi.fn(), + deleteById: vi.fn(), + countsByStatus: vi.fn().mockResolvedValue({ pending: 1, ready: 2, error: 0, total: 3 }), + }; + const gateway = { + emitSummary: vi.fn(), + }; + const service = new BookBucketWatcherService(config as never, ingestService as never, repo as never, gateway as never); + return { service, ingestService, repo, gateway }; +} + +describe('BookBucketWatcherService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rescan walks files and emits summary', async () => { + const { service } = makeService(); + const walkSpy = vi.spyOn(service as any, 'walkAndIngest').mockResolvedValue(undefined); + const emitSpy = vi.spyOn(service as any, 'emitSummary').mockResolvedValue(undefined); + + await service.rescan(); + + expect(walkSpy).toHaveBeenCalledWith('/data/book-bucket'); + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + + it('startWatcher ensures directory exists and subscribes for file events', async () => { + const { service } = makeService(); + vi.mocked(subscribe).mockResolvedValue({ unsubscribe: vi.fn() } as never); + + await (service as any).startWatcher(); + + expect(mkdir).toHaveBeenCalledWith('/data/book-bucket', { recursive: true }); + expect(subscribe).toHaveBeenCalledWith('/data/book-bucket', expect.any(Function)); + }); + + it('startWatcher swallows watcher boot errors', async () => { + const { service } = makeService(); + vi.mocked(subscribe).mockRejectedValue(new Error('watch init failed')); + + await expect((service as any).startWatcher()).resolves.toBeUndefined(); + }); + + it('process(create) waits for stability and ingests supported file types', async () => { + const { service, ingestService, gateway } = makeService(); + vi.mocked(isPrimaryFormat).mockReturnValue(true); + vi.mocked(waitForStability).mockResolvedValue(undefined); + ingestService.ingestFromWatchedFolder.mockResolvedValue(42); + + await (service as any).process('create', '/data/book-bucket/book.epub'); + + expect(waitForStability).toHaveBeenCalledWith('/data/book-bucket/book.epub'); + expect(ingestService.ingestFromWatchedFolder).toHaveBeenCalledWith('/data/book-bucket/book.epub'); + expect(gateway.emitSummary).toHaveBeenCalledWith({ pending: 1, ready: 2, error: 0, total: 3 }); + }); + + it('process(delete) removes db row and cover files before emitting summary', async () => { + const { service, repo, gateway } = makeService(); + repo.findByAbsolutePath.mockResolvedValue({ id: 12, coverPath: '/data/book-bucket/covers/12.jpg' }); + + await (service as any).process('delete', '/data/book-bucket/book.epub'); + + expect(unlink).toHaveBeenCalledWith('/data/book-bucket/covers/12.jpg'); + expect(unlink).toHaveBeenCalledWith('/data/book-bucket/covers/12_thumb.jpg'); + expect(repo.deleteById).toHaveBeenCalledWith(12); + expect(gateway.emitSummary).toHaveBeenCalledWith({ pending: 1, ready: 2, error: 0, total: 3 }); + }); + + it('walkAndIngest skips covers folder and ingests supported files recursively', async () => { + const { service, ingestService } = makeService(); + vi.mocked(isPrimaryFormat).mockImplementation((path: string) => path.endsWith('.epub') || path.endsWith('.pdf')); + vi.mocked(readdir).mockImplementation((dir: string) => { + if (dir === '/data/book-bucket') { + return Promise.resolve([ + { name: 'covers', isDirectory: () => true, isFile: () => false }, + { name: 'nested', isDirectory: () => true, isFile: () => false }, + { name: 'root.epub', isDirectory: () => false, isFile: () => true }, + ] as any); + } + if (dir === '/data/book-bucket/nested') { + return Promise.resolve([ + { name: 'inner.pdf', isDirectory: () => false, isFile: () => true }, + { name: 'note.txt', isDirectory: () => false, isFile: () => true }, + ] as any); + } + return Promise.resolve([] as any); + }); + + await (service as any).walkAndIngest('/data/book-bucket'); + + expect(ingestService.ingestFromWatchedFolder).toHaveBeenCalledWith('/data/book-bucket/root.epub'); + expect(ingestService.ingestFromWatchedFolder).toHaveBeenCalledWith('/data/book-bucket/nested/inner.pdf'); + expect(ingestService.ingestFromWatchedFolder).not.toHaveBeenCalledWith('/data/book-bucket/nested/note.txt'); + }); + + it('onModuleDestroy clears timers and unsubscribes active watcher', async () => { + const { service } = makeService(); + const unsubscribe = vi.fn().mockResolvedValue(undefined); + (service as any).subscription = { unsubscribe }; + const timer = setTimeout(() => undefined, 1_000); + (service as any).pendingTimers.set('/tmp/file.epub', { timer, type: 'create' }); + + await service.onModuleDestroy(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect((service as any).pendingTimers.size).toBe(0); + expect((service as any).subscription).toBeNull(); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket.controller.test.ts b/server/src/modules/book-bucket/book-bucket.controller.test.ts new file mode 100644 index 000000000..74960b813 --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket.controller.test.ts @@ -0,0 +1,154 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Readable } from 'stream'; + +import { BookBucketController } from './book-bucket.controller'; + +vi.mock('fs', () => ({ + createReadStream: vi.fn(() => ({ kind: 'stream' })), +})); + +vi.mock('fs/promises', () => ({ + access: vi.fn(), +})); + +vi.mock('../../common/image-content-type', () => ({ + imageContentTypeFromPath: vi.fn(() => 'image/jpeg'), +})); + +import { access } from 'fs/promises'; +import { createReadStream } from 'fs'; + +function makeController() { + const service = { + listFiles: vi.fn(), + getSummary: vi.fn(), + getStatistics: vi.fn(), + getFile: vi.fn(), + updateFile: vi.fn(), + discardFile: vi.fn(), + bulkDiscard: vi.fn(), + bulkApplyFetched: vi.fn(), + bulkRetryFetch: vi.fn(), + bulkSetTarget: vi.fn(), + selectionSummary: vi.fn(), + bulkEdit: vi.fn(), + }; + const ingestService = { ingestUpload: vi.fn() }; + const finalizeService = { previewNames: vi.fn(), finalize: vi.fn() }; + const watcherService = { rescan: vi.fn() }; + const repo = { findById: vi.fn() }; + + const controller = new BookBucketController( + service as never, + ingestService as never, + finalizeService as never, + watcherService as never, + repo as never, + ); + + return { controller, service, ingestService, finalizeService, watcherService, repo }; +} + +describe('BookBucketController', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('listFiles applies defaults before delegating', async () => { + const { controller, service } = makeController(); + service.listFiles.mockResolvedValue({ items: [], total: 0, page: 1, size: 20 }); + + await controller.listFiles({}); + + expect(service.listFiles).toHaveBeenCalledWith({ + status: undefined, + page: 1, + limit: 20, + sort: 'createdAt', + order: 'desc', + search: undefined, + }); + }); + + it('getCover throws when file has no cover path or cover file does not exist', async () => { + const { controller, repo } = makeController(); + const reply = { header: vi.fn(), send: vi.fn() } as any; + + repo.findById.mockResolvedValueOnce({ id: 1, coverPath: null }); + await expect(controller.getCover(1, reply)).rejects.toBeInstanceOf(NotFoundException); + + repo.findById.mockResolvedValueOnce({ id: 1, coverPath: '/covers/1.jpg' }); + vi.mocked(access).mockRejectedValueOnce(new Error('ENOENT')); + await expect(controller.getCover(1, reply)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('getCover streams cover bytes with proper headers', async () => { + const { controller, repo } = makeController(); + repo.findById.mockResolvedValue({ id: 1, coverPath: '/covers/1.jpg' }); + vi.mocked(access).mockResolvedValue(undefined as never); + const reply = { + header: vi.fn().mockReturnThis(), + send: vi.fn(), + } as any; + + await controller.getCover(1, reply); + + expect(createReadStream).toHaveBeenCalledWith('/covers/1.jpg'); + expect(reply.header).toHaveBeenCalledWith('Content-Type', 'image/jpeg'); + expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'private, max-age=3600'); + expect(reply.send).toHaveBeenCalledWith({ kind: 'stream' }); + }); + + it('upload rejects requests with no multipart file', async () => { + const { controller } = makeController(); + const req = { + file: vi.fn().mockResolvedValue(null), + } as any; + + await expect(controller.upload(req)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('upload ingests file and returns hydrated row', async () => { + const { controller, ingestService, service } = makeController(); + const req = { + file: vi.fn().mockResolvedValue({ + filename: 'book.epub', + file: Readable.from('book'), + }), + } as any; + ingestService.ingestUpload.mockResolvedValue(44); + service.getFile.mockResolvedValue({ id: 44, fileName: 'book.epub' }); + + await expect(controller.upload(req)).resolves.toEqual({ id: 44, fileName: 'book.epub' }); + expect(ingestService.ingestUpload).toHaveBeenCalledWith('book.epub', expect.any(Readable)); + expect(service.getFile).toHaveBeenCalledWith(44); + }); + + it('bulk and finalize endpoints delegate payload fields as expected', async () => { + const { controller, service, finalizeService, watcherService } = makeController(); + + await controller.bulkDiscard({ fileIds: [1], selectAll: false, excludedIds: [2], status: 'error', search: 'x' }); + await controller.applyFetched({ fileIds: [1], selectAll: true, excludedIds: [2], status: 'ready', search: 'x' }); + await controller.retryFetch({ fileIds: [3], selectAll: false, excludedIds: [4], status: 'error', search: 'y' }); + await controller.setTarget({ fileIds: [5], selectAll: false, excludedIds: [6], targetLibraryId: undefined, targetFolderId: undefined }); + await controller.selectionSummary({ fileIds: [7], selectAll: false, excludedIds: [8] }); + await controller.bulkEdit({ + fileIds: [9], + selectAll: false, + excludedIds: [], + fields: { title: 'Edited' }, + enabledFields: ['title'], + mergeArrays: false, + } as any); + await controller.previewNames({ fileIds: [10], selectAll: false, excludedIds: [], defaultLibraryId: 2 } as any); + await controller.finalize( + { id: 99, isSuperuser: true } as any, + { fileIds: [1], defaultLibraryId: 2, defaultFolderId: 3, selectAll: false, excludedIds: [], overrides: [] } as any, + ); + await controller.rescan(); + + expect(service.bulkSetTarget).toHaveBeenCalledWith([5], false, [6], null, null, undefined, undefined); + expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, [1], false, [], 2, 3, [], undefined, undefined); + expect(watcherService.rescan).toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket.dto.test.ts b/server/src/modules/book-bucket/book-bucket.dto.test.ts new file mode 100644 index 000000000..4293f11e1 --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket.dto.test.ts @@ -0,0 +1,76 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { BulkEditBookBucketDto, BulkSetTargetDto, FinalizeBookBucketDto, UpdateBookBucketFileDto } from './dto'; +import { ListBookBucketFilesDto } from './dto/list-book-bucket-files.dto'; + +async function errorsFor(dtoClass: new () => T, value: Record) { + return validate(plainToInstance(dtoClass, value)); +} + +describe('BookBucket DTO validation', () => { + it('ListBookBucketFilesDto validates enums and transforms pagination numbers', async () => { + const dto = plainToInstance(ListBookBucketFilesDto, { + status: 'ready', + page: '2', + limit: '30', + sort: 'fileName', + order: 'asc', + search: 'dune', + }); + + expect(dto.page).toBe(2); + expect(dto.limit).toBe(30); + expect((await validate(dto)).length).toBe(0); + expect((await errorsFor(ListBookBucketFilesDto, { status: 'unknown' })).length).toBeGreaterThan(0); + expect((await errorsFor(ListBookBucketFilesDto, { limit: 200 })).length).toBeGreaterThan(0); + }); + + it('FinalizeBookBucketDto requires default destination values to be complete pair', async () => { + expect((await errorsFor(FinalizeBookBucketDto, { defaultLibraryId: 1 })).length).toBeGreaterThan(0); + expect((await errorsFor(FinalizeBookBucketDto, { defaultFolderId: 2 })).length).toBeGreaterThan(0); + expect((await errorsFor(FinalizeBookBucketDto, { defaultLibraryId: 1, defaultFolderId: 2, fileIds: [1, 2] })).length).toBe(0); + }); + + it('UpdateBookBucketFileDto allows null destination values but rejects non-integers', async () => { + expect((await errorsFor(UpdateBookBucketFileDto, { targetLibraryId: null, targetFolderId: null })).length).toBe(0); + expect((await errorsFor(UpdateBookBucketFileDto, { targetLibraryId: 'x' })).length).toBeGreaterThan(0); + expect((await errorsFor(UpdateBookBucketFileDto, { targetFolderId: 2.5 })).length).toBeGreaterThan(0); + }); + + it('BulkSetTargetDto validates ids, filters, and nullable target fields', async () => { + expect( + ( + await errorsFor(BulkSetTargetDto, { + fileIds: [1, 2], + excludedIds: [3], + selectAll: true, + status: 'error', + search: 'abc', + targetLibraryId: null, + targetFolderId: null, + }) + ).length, + ).toBe(0); + expect((await errorsFor(BulkSetTargetDto, { fileIds: ['bad'] as never })).length).toBeGreaterThan(0); + expect((await errorsFor(BulkSetTargetDto, { targetLibraryId: 'bad' })).length).toBeGreaterThan(0); + }); + + it('BulkEditBookBucketDto requires nested fields and explicit edit controls', async () => { + expect( + ( + await errorsFor(BulkEditBookBucketDto, { + fields: { title: 'Edited Title', authors: ['A'] }, + enabledFields: ['title', 'authors'], + mergeArrays: false, + }) + ).length, + ).toBe(0); + + expect((await errorsFor(BulkEditBookBucketDto, { enabledFields: ['title'], mergeArrays: false })).length).toBeGreaterThan(0); + expect((await errorsFor(BulkEditBookBucketDto, { fields: {}, mergeArrays: false })).length).toBeGreaterThan(0); + expect((await errorsFor(BulkEditBookBucketDto, { fields: {}, enabledFields: ['title'] })).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket.gateway.test.ts b/server/src/modules/book-bucket/book-bucket.gateway.test.ts new file mode 100644 index 000000000..08fc59476 --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket.gateway.test.ts @@ -0,0 +1,74 @@ +import { BookBucketGateway } from './book-bucket.gateway'; + +function makeGateway() { + const jwtService = { verify: vi.fn() }; + const authService = { validateUser: vi.fn() }; + const gateway = new BookBucketGateway(jwtService as any, authService as any); + return { gateway, jwtService, authService }; +} + +describe('BookBucketGateway', () => { + it('disconnects sockets when auth token is missing', async () => { + const { gateway } = makeGateway(); + const client = { + id: 'socket-1', + handshake: { auth: {} }, + data: {}, + disconnect: vi.fn(), + } as any; + + await gateway.handleConnection(client); + + expect(client.disconnect).toHaveBeenCalledTimes(1); + }); + + it('disconnects sockets when user cannot be validated', async () => { + const { gateway, jwtService, authService } = makeGateway(); + jwtService.verify.mockReturnValue({ sub: 8, ver: 3 }); + authService.validateUser.mockResolvedValue(null); + const client = { + id: 'socket-2', + handshake: { auth: { token: 'jwt' } }, + data: {}, + disconnect: vi.fn(), + } as any; + + await gateway.handleConnection(client); + + expect(authService.validateUser).toHaveBeenCalledWith(8, 3); + expect(client.disconnect).toHaveBeenCalledTimes(1); + }); + + it('stores validated user on socket data and keeps socket connected', async () => { + const { gateway, jwtService, authService } = makeGateway(); + const user = { id: 9, isSuperuser: false, permissions: [] }; + jwtService.verify.mockReturnValue({ sub: 9, ver: 4 }); + authService.validateUser.mockResolvedValue(user); + const client = { + id: 'socket-3', + handshake: { auth: { token: 'jwt' } }, + data: {}, + disconnect: vi.fn(), + } as any; + + await gateway.handleConnection(client); + + expect(client.data.user).toEqual(user); + expect(client.disconnect).not.toHaveBeenCalled(); + }); + + it('emitSummary broadcasts summary payload on websocket namespace', () => { + const { gateway } = makeGateway(); + const emit = vi.fn(); + gateway.server = { emit } as any; + + gateway.emitSummary({ pending: 2, ready: 4, error: 1, total: 7 }); + + expect(emit).toHaveBeenCalledWith('book-bucket:summary', { + pending: 2, + ready: 4, + error: 1, + total: 7, + }); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket.module.test.ts b/server/src/modules/book-bucket/book-bucket.module.test.ts new file mode 100644 index 000000000..99eb73b45 --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket.module.test.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata'; + +vi.mock('../app-settings/app-settings.module', () => ({ AppSettingsModule: class AppSettingsModule {} })); +vi.mock('../auth/auth.module', () => ({ AuthModule: class AuthModule {} })); +vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} })); +vi.mock('../metadata-fetch/metadata-fetch.module', () => ({ MetadataFetchModule: class MetadataFetchModule {} })); +vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} })); +vi.mock('../upload/upload.module', () => ({ UploadModule: class UploadModule {} })); + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { BookBucketController } from './book-bucket.controller'; +import { BookBucketEventsService } from './book-bucket-events.service'; +import { BookBucketFinalizeService } from './book-bucket-finalize.service'; +import { BookBucketGateway } from './book-bucket.gateway'; +import { BookBucketIngestService } from './book-bucket-ingest.service'; +import { BookBucketMetadataService } from './book-bucket-metadata.service'; +import { BookBucketModule } from './book-bucket.module'; +import { BookBucketRepository } from './book-bucket.repository'; +import { BookBucketService } from './book-bucket.service'; +import { BookBucketWatcherService } from './book-bucket-watcher.service'; + +describe('BookBucketModule', () => { + it('registers expected controller and provider graph', () => { + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, BookBucketModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, BookBucketModule) as Array; + + expect(controllers).toEqual([BookBucketController]); + expect(providers).toEqual( + expect.arrayContaining([ + BookBucketService, + BookBucketRepository, + BookBucketEventsService, + BookBucketIngestService, + BookBucketMetadataService, + BookBucketFinalizeService, + BookBucketWatcherService, + BookBucketGateway, + ]), + ); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket.repository.test.ts b/server/src/modules/book-bucket/book-bucket.repository.test.ts new file mode 100644 index 000000000..28098d1ea --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket.repository.test.ts @@ -0,0 +1,205 @@ +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })), + asc: vi.fn((value: unknown) => ({ op: 'asc', value })), + count: vi.fn(() => ({ op: 'count' })), + desc: vi.fn((value: unknown) => ({ op: 'desc', value })), + eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })), + gt: vi.fn((left: unknown, right: unknown) => ({ op: 'gt', left, right })), + ilike: vi.fn((left: unknown, right: unknown) => ({ op: 'ilike', left, right })), + inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })), + notInArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'notInArray', left, right })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })), + { + join: vi.fn((chunks: unknown[], separator: unknown) => ({ op: 'sql.join', chunks, separator })), + }, + ), + sum: vi.fn((value: unknown) => ({ op: 'sum', value })), +})); + +import { BookBucketRepository } from './book-bucket.repository'; + +function makeDb() { + const selectBuilder = { + from: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + offset: vi.fn(), + groupBy: vi.fn(), + }; + selectBuilder.from.mockReturnValue(selectBuilder); + selectBuilder.where.mockReturnValue(selectBuilder); + selectBuilder.orderBy.mockReturnValue(selectBuilder); + selectBuilder.offset.mockResolvedValue([]); + selectBuilder.limit.mockReturnValue(selectBuilder); + selectBuilder.groupBy.mockResolvedValue([]); + + const updateBuilder = { + set: vi.fn(), + where: vi.fn(), + returning: vi.fn(), + }; + updateBuilder.set.mockReturnValue(updateBuilder); + updateBuilder.where.mockReturnValue(updateBuilder); + updateBuilder.returning.mockResolvedValue([]); + + const deleteBuilder = { + where: vi.fn(), + returning: vi.fn(), + }; + deleteBuilder.where.mockReturnValue(deleteBuilder); + deleteBuilder.returning.mockResolvedValue([]); + + const insertBuilder = { + values: vi.fn(), + returning: vi.fn(), + }; + insertBuilder.values.mockReturnValue(insertBuilder); + insertBuilder.returning.mockResolvedValue([]); + + return { + db: { + select: vi.fn().mockReturnValue(selectBuilder), + insert: vi.fn().mockReturnValue(insertBuilder), + update: vi.fn().mockReturnValue(updateBuilder), + delete: vi.fn().mockReturnValue(deleteBuilder), + }, + selectBuilder, + insertBuilder, + updateBuilder, + deleteBuilder, + }; +} + +describe('BookBucketRepository', () => { + it('findByIds/deleteByIds/setTargetsByIds short-circuit empty selections', async () => { + const { db } = makeDb(); + const repo = new BookBucketRepository(db as never); + + await expect(repo.findByIds([])).resolves.toEqual([]); + await expect(repo.deleteByIds([])).resolves.toBeUndefined(); + await expect(repo.setTargetsByIds([], 1, 2)).resolves.toBe(0); + + expect(db.select).not.toHaveBeenCalled(); + expect(db.delete).not.toHaveBeenCalled(); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('countsByStatus maps queue statuses and computes total', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.groupBy.mockResolvedValue([ + { status: 'pending', cnt: '3' }, + { status: 'ready', cnt: '5' }, + { status: 'error', cnt: '2' }, + { status: 'extracting', cnt: '1' }, + ]); + const repo = new BookBucketRepository(db as never); + + await expect(repo.countsByStatus()).resolves.toEqual({ + pending: 3, + ready: 5, + error: 2, + total: 11, + }); + }); + + it('getStatistics normalizes null formats and aggregates total size', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.groupBy.mockResolvedValue([ + { format: 'epub', cnt: '2', totalSize: '2000' }, + { format: null, cnt: '1', totalSize: null }, + ]); + const repo = new BookBucketRepository(db as never); + + await expect(repo.getStatistics()).resolves.toEqual({ + totalSizeBytes: 2000, + byFormat: [ + { format: 'epub', count: 2, sizeBytes: 2000 }, + { format: 'unknown', count: 1, sizeBytes: 0 }, + ], + }); + }); + + it('findAllIds returns id list from filtered selection query', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.where.mockResolvedValue([{ id: 4 }, { id: 7 }]); + const repo = new BookBucketRepository(db as never); + + await expect(repo.findAllIds([1], 'pending', 'dune')).resolves.toEqual([4, 7]); + }); + + it('findSelectionBatch applies deterministic ordering and limit', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.limit.mockResolvedValue([{ id: 11 }, { id: 12 }]); + const repo = new BookBucketRepository(db as never); + + await expect(repo.findSelectionBatch({ limit: 2, afterId: 10, excludedIds: [3], status: 'error', search: 'foo' })).resolves.toEqual([ + { id: 11 }, + { id: 12 }, + ]); + expect(selectBuilder.orderBy).toHaveBeenCalled(); + expect(selectBuilder.limit).toHaveBeenCalledWith(2); + }); + + it('findAll returns paged rows and scalar total using status/search filters', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.where.mockReturnValueOnce(selectBuilder).mockResolvedValueOnce([{ total: 7 }]); + selectBuilder.offset.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); + const repo = new BookBucketRepository(db as never); + + await expect( + repo.findAll({ + status: 'pending', + page: 2, + limit: 2, + sort: 'fileName', + order: 'asc', + search: 'dune', + }), + ).resolves.toEqual({ + items: [{ id: 1 }, { id: 2 }], + total: 7, + }); + }); + + it('findById and findByAbsolutePath return the first selected row', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.limit.mockResolvedValueOnce([{ id: 9 }]).mockResolvedValueOnce([{ id: 10 }]); + const repo = new BookBucketRepository(db as never); + + await expect(repo.findById(9)).resolves.toEqual({ id: 9 }); + await expect(repo.findByAbsolutePath('/bucket/10.epub')).resolves.toEqual({ id: 10 }); + }); + + it('create and update return first returning row or undefined when update misses', async () => { + const { db, insertBuilder, updateBuilder } = makeDb(); + insertBuilder.returning.mockResolvedValueOnce([{ id: 1, fileName: 'book.epub' }]); + updateBuilder.returning.mockResolvedValueOnce([{ id: 1, status: 'ready' }]).mockResolvedValueOnce([]); + const repo = new BookBucketRepository(db as never); + + await expect(repo.create({ fileName: 'book.epub' } as never)).resolves.toEqual({ id: 1, fileName: 'book.epub' }); + await expect(repo.update(1, { status: 'ready' } as never)).resolves.toEqual({ id: 1, status: 'ready' }); + await expect(repo.update(999, { status: 'error' } as never)).resolves.toBeUndefined(); + }); + + it('deleteById and deleteByAbsolutePath issue delete queries', async () => { + const { db } = makeDb(); + const repo = new BookBucketRepository(db as never); + + await repo.deleteById(1); + await repo.deleteByAbsolutePath('/bucket/book.epub'); + + expect(db.delete).toHaveBeenCalledTimes(2); + }); + + it('findByIds and setTargetsByIds work for non-empty id sets', async () => { + const { db, selectBuilder, updateBuilder } = makeDb(); + selectBuilder.where.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); + updateBuilder.returning.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); + const repo = new BookBucketRepository(db as never); + + await expect(repo.findByIds([1, 2])).resolves.toEqual([{ id: 1 }, { id: 2 }]); + await expect(repo.setTargetsByIds([1, 2], 4, 5)).resolves.toBe(2); + }); +}); diff --git a/server/src/modules/book-bucket/book-bucket.service.test.ts b/server/src/modules/book-bucket/book-bucket.service.test.ts new file mode 100644 index 000000000..cdd58ead9 --- /dev/null +++ b/server/src/modules/book-bucket/book-bucket.service.test.ts @@ -0,0 +1,217 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { BookBucketService } from './book-bucket.service'; + +vi.mock('fs/promises', () => ({ + unlink: vi.fn(), +})); + +import { unlink } from 'fs/promises'; + +function row(overrides?: Record) { + return { + id: 1, + fileName: 'book.epub', + fileSize: 1024, + format: 'epub', + status: 'ready', + embeddedMetadata: { title: 'Embedded' }, + selectedMetadata: null, + fetchedMetadata: null, + targetLibraryId: null, + targetFolderId: null, + confidence: 80, + fetchedMetadataSources: null, + errorMessage: null, + metadataEditedAt: null, + absolutePath: '/bucket/book.epub', + coverPath: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; +} + +function makeService() { + const db = { select: vi.fn() }; + const repo = { + findAll: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + deleteById: vi.fn(), + deleteByIds: vi.fn(), + findByIds: vi.fn(), + findSelectionBatch: vi.fn(), + setTargetsByIds: vi.fn(), + countsByStatus: vi.fn(), + getStatistics: vi.fn(), + }; + const ingestService = { retryFetch: vi.fn() }; + const service = new BookBucketService(db as never, repo as never, ingestService as never); + return { service, db, repo, ingestService }; +} + +describe('BookBucketService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps list rows to API DTOs with pagination fields', async () => { + const { service, repo } = makeService(); + repo.findAll.mockResolvedValue({ items: [row()], total: 1 }); + + const result = await service.listFiles({ + page: 2, + limit: 25, + sort: 'createdAt', + order: 'desc', + }); + + expect(result.total).toBe(1); + expect(result.page).toBe(2); + expect(result.size).toBe(25); + expect(result.items[0]).toEqual( + expect.objectContaining({ + id: 1, + fileName: 'book.epub', + createdAt: '2026-01-01T00:00:00.000Z', + }), + ); + }); + + it('getFile throws when row is missing', async () => { + const { service, repo } = makeService(); + repo.findById.mockResolvedValue(undefined); + + await expect(service.getFile(123)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('updateFile validates destination and stamps metadataEditedAt for selected metadata updates', async () => { + const { service, repo } = makeService(); + repo.findById.mockResolvedValue(row()); + repo.update.mockResolvedValue(row({ selectedMetadata: { title: 'Edited' } })); + const assertValidTarget = vi.spyOn(service as any, 'assertValidTarget').mockResolvedValue(undefined); + + await service.updateFile(1, { + selectedMetadata: { title: 'Edited' }, + targetLibraryId: 5, + targetFolderId: 9, + }); + + expect(assertValidTarget).toHaveBeenCalledWith(5, 9); + expect(repo.update).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + selectedMetadata: { title: 'Edited' }, + metadataEditedAt: expect.any(Date), + }), + ); + }); + + it('updateFile throws when row disappears during update', async () => { + const { service, repo } = makeService(); + repo.findById.mockResolvedValue(row()); + repo.update.mockResolvedValue(undefined); + + await expect(service.updateFile(1, {})).rejects.toBeInstanceOf(NotFoundException); + }); + + it('bulkApplyFetched counts applied, skipped, and skipped-edited records', async () => { + const { service, repo } = makeService(); + repo.findByIds.mockResolvedValue([ + row({ id: 1, metadataEditedAt: new Date() }), + row({ id: 2, fetchedMetadata: null }), + row({ id: 3, fetchedMetadata: { title: 'Fetched' }, metadataEditedAt: null }), + ]); + + const result = await service.bulkApplyFetched([1, 2, 3]); + + expect(result).toEqual({ + total: 3, + applied: 1, + skipped: 1, + skippedEdited: 1, + }); + expect(repo.update).toHaveBeenCalledWith(3, { + selectedMetadata: { title: 'Fetched' }, + metadataEditedAt: null, + }); + }); + + it('bulkRetryFetch only queues rows currently in error status', async () => { + const { service, repo, ingestService } = makeService(); + repo.findByIds.mockResolvedValue([row({ id: 1, status: 'error' }), row({ id: 2, status: 'ready' }), row({ id: 3, status: 'error' })]); + + const result = await service.bulkRetryFetch([1, 2, 3, 3]); + + expect(result).toEqual({ total: 3, queued: 2 }); + expect(ingestService.retryFetch).toHaveBeenCalledTimes(2); + expect(ingestService.retryFetch).toHaveBeenCalledWith(1); + expect(ingestService.retryFetch).toHaveBeenCalledWith(3); + }); + + it('bulkSetTarget enforces complete destination tuple and returns update counts', async () => { + const { service, repo } = makeService(); + await expect(service.bulkSetTarget([1], false, [], 10, undefined)).rejects.toBeInstanceOf(BadRequestException); + + const assertValidTarget = vi.spyOn(service as any, 'assertValidTarget').mockResolvedValue(undefined); + repo.findByIds.mockResolvedValue([row({ id: 1 }), row({ id: 2 })]); + repo.setTargetsByIds.mockResolvedValue(1); + + await expect(service.bulkSetTarget([1, 2], false, [], 11, 22)).resolves.toEqual({ + total: 2, + updated: 1, + failed: 1, + }); + expect(assertValidTarget).toHaveBeenCalledWith(11, 22); + }); + + it('selectionSummary counts valid destination mappings by folder-library match', async () => { + const { service, repo, db } = makeService(); + repo.findByIds.mockResolvedValue([ + row({ id: 1, targetLibraryId: 1, targetFolderId: 10 }), + row({ id: 2, targetLibraryId: 1, targetFolderId: 11 }), + row({ id: 3, targetLibraryId: null, targetFolderId: null }), + ]); + db.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { id: 10, libraryId: 1 }, + { id: 11, libraryId: 2 }, + ]), + }), + }); + + await expect(service.selectionSummary([1, 2, 3])).resolves.toEqual({ + total: 3, + withDestination: 1, + withoutDestination: 2, + }); + }); + + it('discard and bulkDiscard clean file/cover artifacts before deleting rows', async () => { + const { service, repo } = makeService(); + repo.findById.mockResolvedValue(row({ id: 1, coverPath: '/covers/1.png' })); + repo.findSelectionBatch + .mockResolvedValueOnce([row({ id: 2, absolutePath: '/bucket/2.epub', coverPath: '/covers/2.png' })]) + .mockResolvedValueOnce([]); + + await service.discardFile(1); + await service.bulkDiscard([], true); + + expect(vi.mocked(unlink)).toHaveBeenCalledWith('/bucket/book.epub'); + expect(vi.mocked(unlink)).toHaveBeenCalledWith('/covers/1.png'); + expect(vi.mocked(unlink)).toHaveBeenCalledWith('/covers/1_thumb.jpg'); + expect(repo.deleteById).toHaveBeenCalledWith(1); + expect(repo.deleteByIds).toHaveBeenCalledWith([2]); + }); + + it('proxies summary and statistics repository queries', async () => { + const { service, repo } = makeService(); + repo.countsByStatus.mockResolvedValue({ pending: 1, ready: 2, error: 3, total: 6 }); + repo.getStatistics.mockResolvedValue({ totalSizeBytes: 10, byFormat: [] }); + + await expect(service.getSummary()).resolves.toEqual({ pending: 1, ready: 2, error: 3, total: 6 }); + await expect(service.getStatistics()).resolves.toEqual({ totalSizeBytes: 10, byFormat: [] }); + }); +}); diff --git a/server/src/modules/book-bucket/dto/index.ts b/server/src/modules/book-bucket/dto/index.ts index f47b96f21..d8fa72e0f 100644 --- a/server/src/modules/book-bucket/dto/index.ts +++ b/server/src/modules/book-bucket/dto/index.ts @@ -313,6 +313,7 @@ export class BulkEditBookBucketDto { search?: string; @ValidateNested() + @IsObject() @Type(() => BookBucketMetadataFieldsDto) fields: BookBucketMetadataFieldsDto; diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-config.service.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-config.service.test.ts index aa35e9938..435a31032 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-config.service.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-config.service.test.ts @@ -75,4 +75,88 @@ describe('BookMetadataFetchConfigService', () => { }, }); }); + + it('setGlobalConfig upserts serialized config payload', async () => { + const { db, service } = makeService(); + const insertChain = { + values: vi.fn(), + onConflictDoUpdate: vi.fn(), + }; + insertChain.values.mockReturnValue(insertChain); + insertChain.onConflictDoUpdate.mockResolvedValue(undefined); + db.insert.mockReturnValue(insertChain); + + await service.setGlobalConfig({ + enabled: true, + triggerOnImport: false, + conditions: { + neverFetched: { enabled: true }, + scoreThreshold: { enabled: false, threshold: 60 }, + missingFields: { enabled: true, fields: ['description'] }, + }, + }); + + expect(insertChain.values).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'book_metadata_fetch_config', + }), + ); + expect(insertChain.onConflictDoUpdate).toHaveBeenCalled(); + }); + + it('isPaused and setPaused read/write paused key as string booleans', async () => { + const { db, service } = makeService(); + db.query.appSettings.findFirst.mockResolvedValueOnce({ value: 'true' }).mockResolvedValueOnce({ value: 'false' }); + const insertChain = { + values: vi.fn(), + onConflictDoUpdate: vi.fn(), + }; + insertChain.values.mockReturnValue(insertChain); + insertChain.onConflictDoUpdate.mockResolvedValue(undefined); + db.insert.mockReturnValue(insertChain); + + await expect(service.isPaused()).resolves.toBe(true); + await expect(service.isPaused()).resolves.toBe(false); + await service.setPaused(true); + + expect(insertChain.values).toHaveBeenCalledWith({ + key: 'book_metadata_fetch_paused', + value: 'true', + }); + }); + + it('getLibraryConfigWithLastRun and recordLibraryRun map persisted run metadata', async () => { + const { db, service } = makeService(); + db.query.appSettings.findFirst.mockResolvedValue({ + value: JSON.stringify(DEFAULT_BOOK_METADATA_FETCH_CONFIG), + }); + db.query.libraries.findFirst.mockResolvedValueOnce({ + bookMetadataFetchConfig: null, + }); + db.query.libraries.findFirst.mockResolvedValueOnce({ + bookMetadataFetchLastRunAt: new Date('2026-01-01T00:00:00.000Z'), + bookMetadataFetchLastQueuedCount: 17, + }); + const updateChain = { + set: vi.fn(), + where: vi.fn(), + }; + updateChain.set.mockReturnValue(updateChain); + updateChain.where.mockResolvedValue(undefined); + db.update.mockReturnValue(updateChain); + + await expect(service.getLibraryConfigWithLastRun(9)).resolves.toEqual({ + ...DEFAULT_BOOK_METADATA_FETCH_CONFIG, + lastRunAt: '2026-01-01T00:00:00.000Z', + lastQueuedCount: 17, + }); + + await service.recordLibraryRun(9, 20); + expect(updateChain.set).toHaveBeenCalledWith( + expect.objectContaining({ + bookMetadataFetchLastRunAt: expect.any(Date), + bookMetadataFetchLastQueuedCount: 20, + }), + ); + }); }); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts index 34d0828ed..199bbbfb6 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-eligibility.service.test.ts @@ -119,4 +119,25 @@ describe('BookMetadataFetchEligibilityService', () => { expect(service.isEligible(book, config)).toBe(false); }); + + it('handles remaining missing-field cases and ignores unknown fields', () => { + const config = baseConfig(); + config.conditions.missingFields.enabled = true; + + const cases: Array<{ field: string; book: BookEligibilityData; eligible: boolean }> = [ + { field: 'authors', book: { ...baseBook(), hasAuthors: false }, eligible: true }, + { field: 'genres', book: { ...baseBook(), hasGenres: false }, eligible: true }, + { field: 'subtitle', book: { ...baseBook(), subtitle: '' }, eligible: true }, + { field: 'publisher', book: { ...baseBook(), publisher: '' }, eligible: true }, + { field: 'publishedYear', book: { ...baseBook(), publishedYear: null }, eligible: true }, + { field: 'language', book: { ...baseBook(), language: '' }, eligible: true }, + { field: 'seriesName', book: { ...baseBook(), seriesName: '' }, eligible: true }, + { field: 'unknown_field', book: baseBook(), eligible: false }, + ]; + + for (const testCase of cases) { + config.conditions.missingFields.fields = [testCase.field as never]; + expect(service.isEligible(testCase.book, config)).toBe(testCase.eligible); + } + }); }); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts new file mode 100644 index 000000000..c52d2e25c --- /dev/null +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts @@ -0,0 +1,444 @@ +import { MetadataProviderKey } from '@projectx/types'; + +import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.service'; +import { BookMetadataFetchOrchestratorService } from './book-metadata-fetch-orchestrator.service'; + +function baseConfig(enabled = true, triggerOnImport = true) { + return { + enabled, + triggerOnImport, + conditions: { + neverFetched: { enabled: true }, + scoreThreshold: { enabled: false, threshold: 60 }, + missingFields: { enabled: false, fields: [] }, + }, + }; +} + +function makeService(withGateway = true) { + const queueRepo = { + resetAllProcessingOnBoot: vi.fn().mockResolvedValue(0), + recoverStuckProcessing: vi.fn().mockResolvedValue(0), + fetchDue: vi.fn().mockResolvedValue([]), + upsertSchedule: vi.fn().mockResolvedValue(0), + markProcessing: vi.fn().mockResolvedValue(true), + markDone: vi.fn().mockResolvedValue(undefined), + markFailed: vi.fn().mockResolvedValue(undefined), + getStatusSummary: vi.fn().mockResolvedValue({ queued: 0, processing: 0, failed: 0 }), + scheduleEligibleBooksInBatches: vi.fn().mockResolvedValue(0), + cancelPending: vi.fn().mockResolvedValue(0), + requeueFailed: vi.fn().mockResolvedValue(0), + }; + const configService = { + getEffectiveConfig: vi.fn().mockResolvedValue(baseConfig()), + getGlobalConfig: vi.fn().mockResolvedValue(baseConfig()), + isPaused: vi.fn().mockResolvedValue(false), + setPaused: vi.fn().mockResolvedValue(undefined), + recordLibraryRun: vi.fn().mockResolvedValue(undefined), + }; + const eligibilityService = { + isEligible: vi.fn().mockReturnValue(true), + }; + const bookReadService = { + findById: vi.fn(), + updateMetadataFields: vi.fn().mockResolvedValue(undefined), + }; + const pipeline = { + runWithSources: vi.fn().mockResolvedValue({ resolved: {}, providerIds: {} }), + }; + const metadataService = { + replaceAuthors: vi.fn().mockResolvedValue(undefined), + replaceGenres: vi.fn().mockResolvedValue(undefined), + replaceNarrators: vi.fn().mockResolvedValue(undefined), + upsertComicMetadata: vi.fn().mockResolvedValue(undefined), + downloadAndSaveCover: vi.fn().mockResolvedValue(true), + }; + const scoreService = { + calculateAndSave: vi.fn().mockResolvedValue(undefined), + }; + const bookMetadataLockService = { + filterResolvedMetadata: vi.fn().mockResolvedValue({ + resolved: {}, + providerIds: {}, + skippedFields: [], + }), + }; + const session = new BookMetadataFetchSessionService(); + const throttleTracker = { + hasAnyActive: vi.fn().mockReturnValue(false), + }; + const gateway = { + emitStatus: vi.fn(), + }; + + const service = new BookMetadataFetchOrchestratorService( + queueRepo as never, + configService as never, + eligibilityService as never, + bookReadService as never, + pipeline as never, + metadataService as never, + scoreService as never, + bookMetadataLockService as never, + session, + throttleTracker as never, + withGateway ? (gateway as never) : undefined, + ); + + return { + service, + queueRepo, + configService, + eligibilityService, + bookReadService, + pipeline, + metadataService, + scoreService, + bookMetadataLockService, + session, + throttleTracker, + gateway, + }; +} + +describe('BookMetadataFetchOrchestratorService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('triggerGlobal returns 0 when no books are queued', async () => { + const { service, queueRepo } = makeService(); + queueRepo.scheduleEligibleBooksInBatches.mockResolvedValue(0); + + await expect(service.triggerGlobal()).resolves.toBe(0); + }); + + it('triggerGlobal increments session and emits status when jobs are queued', async () => { + const { service, queueRepo, session, gateway } = makeService(); + queueRepo.scheduleEligibleBooksInBatches.mockResolvedValue(3); + vi.spyOn(service as any, 'pollOnce').mockResolvedValue(undefined); + + await expect(service.triggerGlobal()).resolves.toBe(3); + expect(session.getSnapshot().sessionTotal).toBe(3); + expect(gateway.emitStatus).toHaveBeenCalled(); + }); + + it('triggerForLibrary skips disabled configs and does not record runs', async () => { + const { service, configService } = makeService(); + configService.getEffectiveConfig.mockResolvedValue(baseConfig(false, true)); + + await expect(service.triggerForLibrary(11)).resolves.toBe(0); + expect(configService.recordLibraryRun).not.toHaveBeenCalled(); + }); + + it('triggerForLibrary records run and emits status for queued jobs', async () => { + const { service, queueRepo, configService, session } = makeService(); + queueRepo.scheduleEligibleBooksInBatches.mockResolvedValue(4); + vi.spyOn(service as any, 'pollOnce').mockResolvedValue(undefined); + + await expect(service.triggerForLibrary(12)).resolves.toBe(4); + expect(configService.recordLibraryRun).toHaveBeenCalledWith(12, 4); + expect(session.getSnapshot().sessionTotal).toBe(4); + }); + + it('scheduleIfEligible only queues when trigger-on-import is enabled and eligibility passes', async () => { + const { service, configService, eligibilityService, queueRepo, bookReadService, session } = makeService(); + configService.getEffectiveConfig.mockResolvedValue(baseConfig(true, true)); + bookReadService.findById.mockResolvedValue({ + book: { + books: { libraryId: 7 }, + book_metadata: { + metadataScore: 50, + lastMetadataFetchAt: null, + title: 'Book', + subtitle: null, + description: null, + publisher: null, + publishedYear: null, + language: null, + pageCount: null, + seriesName: null, + seriesIndex: null, + coverSource: null, + durationSeconds: null, + abridged: null, + }, + }, + authorRows: [], + genreRows: [], + narratorRows: [], + }); + eligibilityService.isEligible.mockReturnValue(true); + queueRepo.upsertSchedule = vi.fn().mockResolvedValue(1); + + await service.scheduleIfEligible(99, 7, 'import' as any); + + expect(queueRepo.upsertSchedule).toHaveBeenCalledWith([99], 'import'); + expect(session.getSnapshot().sessionTotal).toBe(1); + }); + + it('pause, resume, cancelPending, and requeueFailed update orchestrator/session state', async () => { + const { service, queueRepo, configService, session } = makeService(); + vi.spyOn(service as any, 'pollOnce').mockResolvedValue(undefined); + queueRepo.requeueFailed.mockResolvedValue(2); + session.addToTotal(5); + session.incrementDone(); + session.setCurrentItemName('Working'); + + await service.pause(); + await service.resume(); + await service.cancelPending(); + await expect(service.requeueFailed()).resolves.toBe(2); + + expect(configService.setPaused).toHaveBeenCalledWith(true); + expect(configService.setPaused).toHaveBeenCalledWith(false); + expect(queueRepo.cancelPending).toHaveBeenCalled(); + expect(session.getSnapshot()).toEqual({ + sessionTotal: 2, + sessionDone: 0, + currentItemName: null, + }); + }); + + it('processOne marks queue row as done when the book no longer exists', async () => { + const { service, queueRepo, bookReadService, session } = makeService(); + session.addToTotal(1); + bookReadService.findById.mockResolvedValue(null); + + await (service as any).processOne(15, 'Missing Book'); + + expect(queueRepo.markDone).toHaveBeenCalledWith(15); + expect(session.getSnapshot().sessionDone).toBe(1); + }); + + it('processOne persists resolved metadata and handles provider ids + related entities', async () => { + const { service, bookMetadataLockService, bookReadService, metadataService } = makeService(); + bookMetadataLockService.filterResolvedMetadata.mockResolvedValue({ + resolved: { + title: 'Resolved', + subtitle: 'Sub', + description: 'Desc', + publisher: 'Pub', + publishedYear: 2020, + language: 'en', + pageCount: 200, + seriesName: 'Series', + seriesIndex: 2, + duration: 3600, + abridged: false, + chapters: [{ title: 'Ch 1', startMs: 0 }], + authors: ['Author A'], + genres: ['Genre A'], + narrators: ['Narrator A'], + coverUrl: 'https://cover', + comicMetadata: { issueNumber: '12' } as any, + }, + providerIds: { + [MetadataProviderKey.GOOGLE]: 'g1', + [MetadataProviderKey.AUDIBLE]: 'a1', + }, + skippedFields: [], + }); + + await (service as any).persistResolved(88, {}, {}, [{ name: 'Old A' }], [{ name: 'Old G' }], [{ name: 'Old N' }]); + + expect(bookReadService.updateMetadataFields).toHaveBeenCalledWith( + 88, + expect.objectContaining({ + title: 'Resolved', + subtitle: 'Sub', + publisher: 'Pub', + durationSeconds: 3600, + abridged: false, + googleBooksId: 'g1', + audibleId: 'a1', + }), + ); + expect(metadataService.replaceAuthors).toHaveBeenCalledWith(88, [{ name: 'Author A', sortName: null }]); + expect(metadataService.replaceGenres).toHaveBeenCalledWith(88, ['Genre A']); + expect(metadataService.replaceNarrators).toHaveBeenCalledWith(88, [{ name: 'Narrator A', sortName: null }]); + expect(metadataService.upsertComicMetadata).toHaveBeenCalledWith(88, { issueNumber: '12' }); + expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith('https://cover', 88); + }); + + it('processOne marks failures with extracted http status', async () => { + const { service, queueRepo, bookReadService, pipeline } = makeService(); + bookReadService.findById.mockResolvedValue({ + book: { + books: { libraryId: 1 }, + book_metadata: { + title: 'Book', + subtitle: null, + description: null, + isbn13: null, + isbn10: null, + publisher: null, + publishedYear: null, + language: null, + pageCount: null, + seriesName: null, + seriesIndex: null, + coverSource: null, + durationSeconds: null, + abridged: null, + }, + }, + authorRows: [], + genreRows: [], + narratorRows: [], + }); + pipeline.runWithSources.mockRejectedValue(Object.assign(new Error('provider down'), { status: 503 })); + + await (service as any).processOne(90, 'Book'); + + expect(queueRepo.markFailed).toHaveBeenCalledWith(90, 'provider down', 503); + }); + + it('emitStatus is a no-op when gateway is not configured', async () => { + const { service, queueRepo } = makeService(false); + queueRepo.getStatusSummary.mockResolvedValue({ queued: 1, processing: 0, failed: 0 }); + + await expect((service as any).emitStatus()).resolves.toBeUndefined(); + }); + + it('onApplicationBootstrap resets processing rows and schedules polling', async () => { + vi.useFakeTimers(); + try { + const { service, queueRepo, configService } = makeService(); + const pollSpy = vi.spyOn(service as any, 'pollOnce').mockResolvedValue(undefined); + configService.isPaused.mockResolvedValue(true); + + await service.onApplicationBootstrap(); + expect(queueRepo.resetAllProcessingOnBoot).toHaveBeenCalledTimes(1); + expect(configService.isPaused).toHaveBeenCalledTimes(1); + expect(pollSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(4_000); + expect(pollSpy).toHaveBeenCalledTimes(2); + service.onModuleDestroy(); + } finally { + vi.useRealTimers(); + } + }); + + it('pollOnce short-circuits when already running', async () => { + const { service, queueRepo } = makeService(); + (service as any).running = true; + + await (service as any).pollOnce(); + + expect(queueRepo.recoverStuckProcessing).not.toHaveBeenCalled(); + }); + + it('pollOnce skips queue processing while paused', async () => { + const { service, queueRepo } = makeService(); + (service as any).paused = true; + const processSpy = vi.spyOn(service as any, 'processOne').mockResolvedValue(undefined); + + await (service as any).pollOnce(); + + expect(queueRepo.recoverStuckProcessing).toHaveBeenCalledTimes(1); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it('pollOnce processes due items and waits random delay', async () => { + const { service, queueRepo } = makeService(); + queueRepo.fetchDue.mockResolvedValue([{ bookId: 44, title: 'Queued Book' }]); + const processSpy = vi.spyOn(service as any, 'processOne').mockResolvedValue(undefined); + const delaySpy = vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined); + + await (service as any).pollOnce(); + + expect(processSpy).toHaveBeenCalledWith(44, 'Queued Book'); + expect(delaySpy).toHaveBeenCalledTimes(1); + }); + + it('processOne exits early when queue row cannot be claimed', async () => { + const { service, queueRepo, bookReadService } = makeService(); + queueRepo.markProcessing.mockResolvedValue(false); + + await (service as any).processOne(3, 'Book'); + + expect(bookReadService.findById).not.toHaveBeenCalled(); + expect(queueRepo.markDone).not.toHaveBeenCalled(); + }); + + it('processOne marks done and clears session when metadata pipeline succeeds', async () => { + const { service, queueRepo, bookReadService, pipeline, scoreService, session } = makeService(); + session.addToTotal(1); + bookReadService.findById.mockResolvedValue({ + book: { + books: { libraryId: 2 }, + book_metadata: { title: 'Book', isbn13: null, isbn10: null, durationSeconds: null, audibleId: null }, + }, + authorRows: [{ name: 'Author' }], + genreRows: [{ name: 'Genre' }], + narratorRows: [], + }); + pipeline.runWithSources.mockResolvedValue({ resolved: {}, providerIds: {} }); + vi.spyOn(service as any, 'persistResolved').mockResolvedValue(undefined); + scoreService.calculateAndSave.mockRejectedValue(new Error('score delayed')); + + await (service as any).processOne(90, 'Book'); + + expect(queueRepo.markDone).toHaveBeenCalledWith(90); + expect(queueRepo.markFailed).not.toHaveBeenCalled(); + expect(session.getSnapshot().sessionDone).toBe(1); + expect(session.getSnapshot().currentItemName).toBeNull(); + }); + + it('randomDelay uses longer delays when provider throttling is active', async () => { + vi.useFakeTimers(); + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0); + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + try { + const { service, throttleTracker } = makeService(); + throttleTracker.hasAnyActive.mockReturnValue(true); + + const waitPromise = (service as any).randomDelay(); + expect(timeoutSpy).toHaveBeenCalledTimes(1); + const delayMs = timeoutSpy.mock.calls[0]?.[1] as number; + expect(delayMs).toBe(10_000); + vi.runAllTimers(); + await waitPromise; + } finally { + randomSpy.mockRestore(); + timeoutSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it('unpauseIfNeeded only writes config when paused and resets sessions when queue is empty', async () => { + const { service, configService, queueRepo, session } = makeService(); + const resetSpy = vi.spyOn(session, 'reset'); + (service as any).paused = false; + + await (service as any).unpauseIfNeeded(); + expect(configService.setPaused).not.toHaveBeenCalledWith(false); + + (service as any).paused = true; + await (service as any).unpauseIfNeeded(); + expect(configService.setPaused).toHaveBeenCalledWith(false); + + queueRepo.getStatusSummary.mockResolvedValueOnce({ queued: 0, processing: 0, failed: 0 }); + await (service as any).checkAndResetSession(); + expect(resetSpy).toHaveBeenCalledTimes(1); + + queueRepo.getStatusSummary.mockResolvedValueOnce({ queued: 1, processing: 0, failed: 0 }); + await (service as any).checkAndResetSession(); + expect(resetSpy).toHaveBeenCalledTimes(1); + }); + + it('scheduleIfEligible skips queue writes when import trigger is disabled or book is missing', async () => { + const { service, configService, queueRepo, bookReadService } = makeService(); + configService.getEffectiveConfig.mockResolvedValue(baseConfig(true, false)); + + await service.scheduleIfEligible(1, 2, 'import' as never); + expect(queueRepo.upsertSchedule).not.toHaveBeenCalled(); + + configService.getEffectiveConfig.mockResolvedValue(baseConfig(true, true)); + bookReadService.findById.mockResolvedValue(null); + await service.scheduleIfEligible(1, 2, 'import' as never); + expect(queueRepo.upsertSchedule).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts index c06ae16ed..8bea4020b 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts @@ -78,6 +78,8 @@ describe('BookMetadataFetchQueueRepository', () => { }, insertBuilder, selectBuilder, + updateBuilder, + deleteBuilder, }; }; @@ -154,4 +156,166 @@ describe('BookMetadataFetchQueueRepository', () => { await expect(repo.countEligibleBooks(config)).resolves.toBe(3); expect(and).toHaveBeenCalled(); }); + + it('markProcessing returns true when queue row is claimed', async () => { + const { db, updateBuilder } = makeDb(); + updateBuilder.returning.mockResolvedValueOnce([{ bookId: 44 }]); + const repo = new BookMetadataFetchQueueRepository(db as never); + + await expect(repo.markProcessing(44)).resolves.toBe(true); + }); + + it('markProcessing returns false on unique-violation races and throws unknown errors', async () => { + const { db, updateBuilder } = makeDb(); + const repo = new BookMetadataFetchQueueRepository(db as never); + + updateBuilder.returning.mockRejectedValueOnce({ code: '23505' }); + await expect(repo.markProcessing(44)).resolves.toBe(false); + + updateBuilder.returning.mockRejectedValueOnce(new Error('db down')); + await expect(repo.markProcessing(44)).rejects.toThrow('db down'); + }); + + it('markFailed truncates long error text and normalizes http status', async () => { + const { db, updateBuilder } = makeDb(); + const repo = new BookMetadataFetchQueueRepository(db as never); + + await repo.markFailed(10, 'x'.repeat(3000)); + + expect(updateBuilder.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + lastError: 'x'.repeat(2000), + lastHttpStatus: null, + }), + ); + }); + + it('cancelPending and requeueFailed return row counts from write results', async () => { + const { db, deleteBuilder, updateBuilder } = makeDb(); + deleteBuilder.returning.mockResolvedValueOnce([{ bookId: 1 }, { bookId: 2 }]); + updateBuilder.returning.mockResolvedValueOnce([{ bookId: 9 }]); + const repo = new BookMetadataFetchQueueRepository(db as never); + + await expect(repo.cancelPending()).resolves.toBe(2); + await expect(repo.requeueFailed()).resolves.toBe(1); + }); + + it('getStatusSummary maps grouped counts by queue status', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.groupBy.mockResolvedValue([ + { status: 'queued', cnt: '5' }, + { status: 'processing', cnt: '2' }, + { status: 'failed', cnt: '1' }, + ]); + const repo = new BookMetadataFetchQueueRepository(db as never); + + await expect(repo.getStatusSummary()).resolves.toEqual({ + queued: 5, + processing: 2, + failed: 1, + }); + }); + + it('resetAllProcessingOnBoot and recoverStuckProcessing return recovered row counts', async () => { + const { db, updateBuilder } = makeDb(); + updateBuilder.returning.mockResolvedValueOnce([{ bookId: 1 }, { bookId: 2 }]).mockResolvedValueOnce([{ bookId: 4 }]); + const repo = new BookMetadataFetchQueueRepository(db as never); + + await expect(repo.resetAllProcessingOnBoot()).resolves.toBe(2); + await expect(repo.recoverStuckProcessing()).resolves.toBe(1); + }); + + it('scheduleEligibleBooksInBatches walks cursor pages and accumulates queued totals', async () => { + const { db, selectBuilder } = makeDb(); + selectBuilder.limit.mockResolvedValueOnce([{ bookId: 2 }, { bookId: 5 }]).mockResolvedValueOnce([]); + const repo = new BookMetadataFetchQueueRepository(db as never); + vi.spyOn(repo, 'upsertSchedule').mockResolvedValueOnce(2); + const config = baseConfig(); + config.conditions.neverFetched.enabled = true; + + await expect(repo.scheduleEligibleBooksInBatches(config, 'manual_trigger', undefined, 2)).resolves.toBe(2); + expect(repo.upsertSchedule).toHaveBeenCalledWith([2, 5], 'manual_trigger'); + }); + + it('fetchDue short-circuits for non-positive limits', async () => { + const { db } = makeDb(); + const repo = new BookMetadataFetchQueueRepository(db as never); + + await expect(repo.fetchDue(0)).resolves.toEqual([]); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('markDone deletes queue rows by book id', async () => { + const { db, deleteBuilder } = makeDb(); + const repo = new BookMetadataFetchQueueRepository(db as never); + + await expect(repo.markDone(9)).resolves.toBeUndefined(); + expect(db.delete).toHaveBeenCalledTimes(1); + expect(deleteBuilder.where).toHaveBeenCalledTimes(1); + }); + + it('scheduleEligibleBooksInBatches returns 0 for non-positive batch sizes', async () => { + const { db } = makeDb(); + const repo = new BookMetadataFetchQueueRepository(db as never); + const config = baseConfig(); + config.conditions.neverFetched.enabled = true; + + await expect(repo.scheduleEligibleBooksInBatches(config, 'manual_trigger', undefined, 0)).resolves.toBe(0); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('getFailedItems maps nullable fields and returns total count', async () => { + const failedRows = [ + { + bookId: 10, + title: null, + libraryName: 'Main', + error: 'provider timeout', + httpStatus: 503, + failedAt: new Date('2026-01-05T00:00:00.000Z'), + }, + ]; + const totalRows = [{ cnt: '4' }]; + const failedItemsChain = { + from: vi.fn(), + leftJoin: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + offset: vi.fn(), + }; + failedItemsChain.from.mockReturnValue(failedItemsChain); + failedItemsChain.leftJoin.mockReturnValue(failedItemsChain); + failedItemsChain.where.mockReturnValue(failedItemsChain); + failedItemsChain.orderBy.mockReturnValue(failedItemsChain); + failedItemsChain.limit.mockReturnValue(failedItemsChain); + failedItemsChain.offset.mockResolvedValue(failedRows); + + const totalChain = { + from: vi.fn(), + where: vi.fn(), + }; + totalChain.from.mockReturnValue(totalChain); + totalChain.where.mockResolvedValue(totalRows); + + const db = { + select: vi.fn().mockReturnValueOnce(failedItemsChain).mockReturnValueOnce(totalChain), + }; + const repo = new BookMetadataFetchQueueRepository(db as never); + + await expect(repo.getFailedItems(2, 10)).resolves.toEqual({ + items: [ + { + bookId: 10, + title: null, + libraryName: 'Main', + error: 'provider timeout', + httpStatus: 503, + failedAt: '2026-01-05T00:00:00.000Z', + }, + ], + total: 4, + }); + }); }); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.test.ts index 4f0e8576d..6f241e8a8 100644 --- a/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.test.ts +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.test.ts @@ -88,4 +88,55 @@ describe('BookMetadataFetchController', () => { ); expect(result).toEqual({ count: 12 }); }); + + it('gets and updates global config through config service', async () => { + configService.getGlobalConfig.mockResolvedValueOnce({ enabled: false }).mockResolvedValueOnce({ enabled: true }); + configService.setGlobalConfig.mockResolvedValue(undefined); + + await expect(controller.getConfig()).resolves.toEqual({ enabled: false }); + await expect(controller.updateConfig({ enabled: true } as never)).resolves.toEqual({ enabled: true }); + expect(configService.setGlobalConfig).toHaveBeenCalledWith({ enabled: true }); + }); + + it('gets library config and writes null override for empty payloads', async () => { + configService.getLibraryConfigWithLastRun.mockResolvedValue({ enabled: true }); + configService.getEffectiveConfig.mockResolvedValue({ enabled: false }); + configService.setLibraryOverride.mockResolvedValue(undefined); + + await expect(controller.getLibraryConfig(22)).resolves.toEqual({ enabled: true }); + await expect(controller.updateLibraryConfig(22, {} as never)).resolves.toEqual({ enabled: false }); + expect(configService.setLibraryOverride).toHaveBeenCalledWith(22, null); + }); + + it('returns status summary merged with paused state and session snapshot', async () => { + queueRepo.getStatusSummary.mockResolvedValue({ queued: 3, processing: 1, failed: 2 }); + configService.isPaused.mockResolvedValue(true); + session.getSnapshot.mockReturnValue({ sessionTotal: 7, sessionDone: 3, currentItemName: 'Book' }); + + await expect(controller.getStatus()).resolves.toEqual({ + queued: 3, + processing: 1, + failed: 2, + paused: true, + sessionTotal: 7, + sessionDone: 3, + currentItemName: 'Book', + }); + }); + + it('run and control endpoints proxy orchestrator return shapes', async () => { + orchestrator.triggerGlobal.mockResolvedValue(5); + orchestrator.triggerForLibrary.mockResolvedValue(2); + orchestrator.requeueFailed.mockResolvedValue(4); + orchestrator.pause.mockResolvedValue(undefined); + orchestrator.resume.mockResolvedValue(undefined); + orchestrator.cancelPending.mockResolvedValue(undefined); + + await expect(controller.triggerGlobal()).resolves.toEqual({ queued: 5 }); + await expect(controller.triggerForLibrary(9)).resolves.toEqual({ queued: 2 }); + await expect(controller.pause()).resolves.toEqual({ paused: true }); + await expect(controller.resume()).resolves.toEqual({ paused: false }); + await expect(controller.cancel()).resolves.toEqual({ cancelled: true }); + await expect(controller.retryFailed()).resolves.toEqual({ requeued: 4 }); + }); }); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch.gateway.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch.gateway.test.ts new file mode 100644 index 000000000..695d09282 --- /dev/null +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch.gateway.test.ts @@ -0,0 +1,118 @@ +import { Permission } from '@projectx/types'; + +import { BOOK_METADATA_FETCH_STATUS_EVENT, BookMetadataFetchGateway } from './book-metadata-fetch.gateway'; + +function makeGateway() { + const jwtService = { verify: vi.fn() }; + const authService = { validateUser: vi.fn() }; + const queueRepo = { getStatusSummary: vi.fn() }; + const configService = { isPaused: vi.fn() }; + const session = { getSnapshot: vi.fn() }; + const gateway = new BookMetadataFetchGateway( + jwtService as never, + authService as never, + queueRepo as never, + configService as never, + session as never, + ); + return { gateway, jwtService, authService, queueRepo, configService, session }; +} + +describe('BookMetadataFetchGateway', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects websocket connection without token', async () => { + const { gateway } = makeGateway(); + const client = { + id: 'sock-1', + handshake: { auth: {} }, + emit: vi.fn(), + disconnect: vi.fn(), + data: {}, + } as any; + + await gateway.handleConnection(client); + + expect(client.disconnect).toHaveBeenCalledTimes(1); + }); + + it('rejects authenticated users lacking metadata-config permission', async () => { + const { gateway, jwtService, authService } = makeGateway(); + jwtService.verify.mockReturnValue({ sub: 5, ver: 3 }); + authService.validateUser.mockResolvedValue({ id: 5, isSuperuser: false, permissions: [] }); + const client = { + id: 'sock-2', + handshake: { auth: { token: 'jwt' } }, + emit: vi.fn(), + disconnect: vi.fn(), + data: {}, + } as any; + + await gateway.handleConnection(client); + + expect(client.disconnect).toHaveBeenCalledTimes(1); + expect(client.emit).not.toHaveBeenCalled(); + }); + + it('emits status snapshot for permitted users', async () => { + const { gateway, jwtService, authService, queueRepo, configService, session } = makeGateway(); + jwtService.verify.mockReturnValue({ sub: 7, ver: 9 }); + authService.validateUser.mockResolvedValue({ + id: 7, + isSuperuser: false, + permissions: [Permission.ManageMetadataConfig], + }); + queueRepo.getStatusSummary.mockResolvedValue({ queued: 1, processing: 2, failed: 3 }); + configService.isPaused.mockResolvedValue(true); + session.getSnapshot.mockReturnValue({ sessionTotal: 9, sessionDone: 4, currentItemName: 'Book' }); + const client = { + id: 'sock-3', + handshake: { auth: { token: 'jwt' } }, + emit: vi.fn(), + disconnect: vi.fn(), + data: {}, + } as any; + + await gateway.handleConnection(client); + + expect(client.data.user).toEqual(expect.objectContaining({ id: 7 })); + expect(client.emit).toHaveBeenCalledWith(BOOK_METADATA_FETCH_STATUS_EVENT, { + queued: 1, + processing: 2, + failed: 3, + paused: true, + sessionTotal: 9, + sessionDone: 4, + currentItemName: 'Book', + }); + expect(client.disconnect).not.toHaveBeenCalled(); + }); + + it('emitStatus broadcasts event to namespace listeners', () => { + const { gateway } = makeGateway(); + const emit = vi.fn(); + gateway.server = { emit } as any; + + gateway.emitStatus({ + queued: 1, + processing: 0, + failed: 0, + paused: false, + sessionTotal: 1, + sessionDone: 0, + currentItemName: null, + }); + + expect(emit).toHaveBeenCalledWith(BOOK_METADATA_FETCH_STATUS_EVENT, { + queued: 1, + processing: 0, + failed: 0, + paused: false, + sessionTotal: 1, + sessionDone: 0, + currentItemName: null, + }); + }); +}); diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch.module.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch.module.test.ts new file mode 100644 index 000000000..60714f172 --- /dev/null +++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch.module.test.ts @@ -0,0 +1,65 @@ +import 'reflect-metadata'; + +vi.mock('../auth/auth.module', () => ({ AuthModule: class AuthModule {} })); +vi.mock('../book/book.module', () => ({ BookModule: class BookModule {} })); +vi.mock('../book-metadata-lock/book-metadata-lock.module', () => ({ BookMetadataLockModule: class BookMetadataLockModule {} })); +vi.mock('../metadata-fetch/metadata-fetch.module', () => ({ MetadataFetchModule: class MetadataFetchModule {} })); +vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} })); +vi.mock('../metadata-score/metadata-score.module', () => ({ MetadataScoreModule: class MetadataScoreModule {} })); + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { BookModule } from '../book/book.module'; +import { BookMetadataFetchConfigService } from './book-metadata-fetch-config.service'; +import { BookMetadataFetchController } from './book-metadata-fetch.controller'; +import { BookMetadataFetchEligibilityService } from './book-metadata-fetch-eligibility.service'; +import { BookMetadataFetchGateway } from './book-metadata-fetch.gateway'; +import { BookMetadataFetchModule } from './book-metadata-fetch.module'; +import { BookMetadataFetchOrchestratorService } from './book-metadata-fetch-orchestrator.service'; +import { BookMetadataFetchQueueRepository } from './book-metadata-fetch-queue.repository'; +import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.service'; + +describe('BookMetadataFetchModule', () => { + it('registers expected controller and provider graph', () => { + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, BookMetadataFetchModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, BookMetadataFetchModule) as Array; + const exports = Reflect.getMetadata(MODULE_METADATA.EXPORTS, BookMetadataFetchModule) as Array; + + expect(controllers).toEqual([BookMetadataFetchController]); + expect(providers).toEqual( + expect.arrayContaining([ + BookMetadataFetchSessionService, + BookMetadataFetchQueueRepository, + BookMetadataFetchEligibilityService, + BookMetadataFetchConfigService, + BookMetadataFetchOrchestratorService, + BookMetadataFetchGateway, + ]), + ); + expect(exports).toEqual([BookMetadataFetchOrchestratorService]); + }); + + it('keeps the book forwardRef import and config-driven jwt factory', () => { + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, BookMetadataFetchModule) as Array<{ + forwardRef?: () => unknown; + providers?: Array<{ useFactory?: (config: { getOrThrow: (key: string) => string }) => unknown }>; + }>; + + const forwardRefImport = imports.find((entry) => typeof entry.forwardRef === 'function'); + expect(forwardRefImport?.forwardRef?.()).toBe(BookModule); + + const jwtFactory = imports.flatMap((entry) => entry.providers ?? []).find((provider) => typeof provider.useFactory === 'function')?.useFactory; + + expect(jwtFactory).toBeDefined(); + const config = { + getOrThrow: vi.fn((key: string) => { + if (key === 'auth.jwtSecret') return 'secret'; + if (key === 'auth.jwtExpiresIn') return '1h'; + throw new Error(`unexpected key: ${key}`); + }), + }; + const options = jwtFactory?.(config) as { secret: string; signOptions: { expiresIn: string } }; + + expect(options).toEqual({ secret: 'secret', signOptions: { expiresIn: '1h' } }); + }); +}); diff --git a/server/src/modules/book-metadata-fetch/update-config.dto.test.ts b/server/src/modules/book-metadata-fetch/update-config.dto.test.ts new file mode 100644 index 000000000..0a35fd3d7 --- /dev/null +++ b/server/src/modules/book-metadata-fetch/update-config.dto.test.ts @@ -0,0 +1,80 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { PreviewCountDto, UpdateBookMetadataFetchConfigDto, UpdateLibraryBookMetadataFetchConfigDto } from './dto/update-config.dto'; + +async function errorsFor(dtoClass: new () => T, value: Record) { + return validate(plainToInstance(dtoClass, value)); +} + +describe('Book metadata fetch config DTO validation', () => { + it('accepts a fully-specified global config payload', async () => { + const payload = { + enabled: true, + triggerOnImport: false, + conditions: { + scoreThreshold: { enabled: true, threshold: 60 }, + missingFields: { enabled: true, fields: ['description', 'narrators', 'duration'] }, + neverFetched: { enabled: true }, + }, + }; + + expect((await errorsFor(UpdateBookMetadataFetchConfigDto, payload)).length).toBe(0); + }); + + it('rejects invalid threshold values and unknown metadata fields', async () => { + const badThreshold = { + enabled: true, + triggerOnImport: true, + conditions: { + scoreThreshold: { enabled: true, threshold: 101 }, + missingFields: { enabled: true, fields: ['description'] }, + neverFetched: { enabled: true }, + }, + }; + const badField = { + enabled: true, + triggerOnImport: true, + conditions: { + scoreThreshold: { enabled: true, threshold: 50 }, + missingFields: { enabled: true, fields: ['not_a_field'] }, + neverFetched: { enabled: true }, + }, + }; + + expect((await errorsFor(UpdateBookMetadataFetchConfigDto, badThreshold)).length).toBeGreaterThan(0); + expect((await errorsFor(UpdateBookMetadataFetchConfigDto, badField)).length).toBeGreaterThan(0); + }); + + it('PreviewCountDto requires nested conditions and validates optional library id', async () => { + const valid = { + conditions: { + scoreThreshold: { enabled: false, threshold: 60 }, + missingFields: { enabled: true, fields: ['cover'] }, + neverFetched: { enabled: true }, + }, + libraryId: 12, + }; + + expect((await errorsFor(PreviewCountDto, valid)).length).toBe(0); + expect((await errorsFor(PreviewCountDto, { libraryId: 12 })).length).toBeGreaterThan(0); + expect((await errorsFor(PreviewCountDto, { ...valid, libraryId: 'x' as never })).length).toBeGreaterThan(0); + }); + + it('UpdateLibraryBookMetadataFetchConfigDto allows partial override payloads', async () => { + expect((await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, { enabled: true })).length).toBe(0); + expect( + ( + await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, { + conditions: { + scoreThreshold: { enabled: true, threshold: 45 }, + missingFields: { enabled: true, fields: ['publisher'] }, + neverFetched: { enabled: false }, + }, + }) + ).length, + ).toBe(0); + }); +}); diff --git a/server/src/modules/book-metadata-lock/book-metadata-lock.dto.test.ts b/server/src/modules/book-metadata-lock/book-metadata-lock.dto.test.ts new file mode 100644 index 000000000..a9b7040c4 --- /dev/null +++ b/server/src/modules/book-metadata-lock/book-metadata-lock.dto.test.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { UpdateBookMetadataLocksDto } from './dto/update-book-metadata-locks.dto'; + +describe('UpdateBookMetadataLocksDto', () => { + it('accepts unique valid lock fields', async () => { + const dto = plainToInstance(UpdateBookMetadataLocksDto, { + lockedFields: ['title', 'authors', 'cover'], + }); + + expect((await validate(dto)).length).toBe(0); + }); + + it('rejects duplicates and unknown lock fields', async () => { + const duplicateDto = plainToInstance(UpdateBookMetadataLocksDto, { + lockedFields: ['title', 'title'], + }); + const unknownDto = plainToInstance(UpdateBookMetadataLocksDto, { + lockedFields: ['not-a-field'], + }); + + expect((await validate(duplicateDto)).length).toBeGreaterThan(0); + expect((await validate(unknownDto)).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/book-metadata-lock/book-metadata-lock.module.test.ts b/server/src/modules/book-metadata-lock/book-metadata-lock.module.test.ts new file mode 100644 index 000000000..0aae27a51 --- /dev/null +++ b/server/src/modules/book-metadata-lock/book-metadata-lock.module.test.ts @@ -0,0 +1,17 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { BookMetadataLockModule } from './book-metadata-lock.module'; +import { BookMetadataLockRepository } from './book-metadata-lock.repository'; +import { BookMetadataLockService } from './book-metadata-lock.service'; + +describe('BookMetadataLockModule', () => { + it('registers and exports metadata lock services', () => { + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, BookMetadataLockModule) as Array; + const exports = Reflect.getMetadata(MODULE_METADATA.EXPORTS, BookMetadataLockModule) as Array; + + expect(providers).toEqual(expect.arrayContaining([BookMetadataLockRepository, BookMetadataLockService])); + expect(exports).toEqual([BookMetadataLockService]); + }); +}); diff --git a/server/src/modules/book-metadata-lock/book-metadata-lock.repository.test.ts b/server/src/modules/book-metadata-lock/book-metadata-lock.repository.test.ts new file mode 100644 index 000000000..bfa3eebb5 --- /dev/null +++ b/server/src/modules/book-metadata-lock/book-metadata-lock.repository.test.ts @@ -0,0 +1,91 @@ +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })), + inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })), + { + join: vi.fn((chunks: unknown[], separator: unknown) => ({ op: 'sql.join', chunks, separator })), + }, + ), +})); + +import { BookMetadataLockRepository } from './book-metadata-lock.repository'; + +function makeDb() { + const selectBuilder = { + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + }; + selectBuilder.from.mockReturnValue(selectBuilder); + selectBuilder.where.mockReturnValue(selectBuilder); + selectBuilder.limit.mockResolvedValue([]); + + const insertBuilder = { + values: vi.fn(), + onConflictDoUpdate: vi.fn(), + }; + insertBuilder.values.mockReturnValue(insertBuilder); + insertBuilder.onConflictDoUpdate.mockResolvedValue(undefined); + + return { + db: { + select: vi.fn().mockReturnValue(selectBuilder), + insert: vi.fn().mockReturnValue(insertBuilder), + }, + selectBuilder, + insertBuilder, + }; +} + +describe('BookMetadataLockRepository', () => { + it('findLockedFields returns an empty list when metadata row is missing', async () => { + const { db } = makeDb(); + const repo = new BookMetadataLockRepository(db as never); + + await expect(repo.findLockedFields(11)).resolves.toEqual([]); + }); + + it('findLockedFieldsByBookIds short-circuits empty ids and maps rows by book id', async () => { + const { db, selectBuilder } = makeDb(); + const repo = new BookMetadataLockRepository(db as never); + + await expect(repo.findLockedFieldsByBookIds([])).resolves.toEqual(new Map()); + expect(db.select).not.toHaveBeenCalled(); + + selectBuilder.where.mockResolvedValueOnce([ + { bookId: 1, lockedFields: ['title'] }, + { bookId: 2, lockedFields: null }, + ]); + await expect(repo.findLockedFieldsByBookIds([1, 2])).resolves.toEqual( + new Map([ + [1, ['title']], + [2, []], + ]), + ); + }); + + it('replaceLockedFields upserts lock array and timestamp', async () => { + const { db, insertBuilder } = makeDb(); + const repo = new BookMetadataLockRepository(db as never); + + await repo.replaceLockedFields(20, ['title', 'authors']); + + expect(db.insert).toHaveBeenCalled(); + expect(insertBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ + bookId: 20, + lockedFields: ['title', 'authors'], + updatedAt: expect.any(Date), + }), + ); + expect(insertBuilder.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ + lockedFields: ['title', 'authors'], + updatedAt: expect.any(Date), + }), + }), + ); + }); +}); diff --git a/server/src/modules/book/book-read.service.test.ts b/server/src/modules/book/book-read.service.test.ts new file mode 100644 index 000000000..3b8dd6ce7 --- /dev/null +++ b/server/src/modules/book/book-read.service.test.ts @@ -0,0 +1,73 @@ +import { BookReadService } from './book-read.service'; + +function makeService() { + const bookRepo = { + findLibraryIdByBookId: vi.fn(), + findFileById: vi.fn(), + findPrimaryFilesByBookIds: vi.fn(), + findCards: vi.fn(), + findCardsByBookIds: vi.fn(), + countWhere: vi.fn(), + findLibraryIdsByBookIds: vi.fn(), + findRecommendationTitlesByBookIds: vi.fn(), + findById: vi.fn(), + updateMetadataFields: vi.fn(), + }; + const service = new BookReadService(bookRepo as never); + return { service, bookRepo }; +} + +describe('BookReadService', () => { + it('delegates read queries to BookRepository', () => { + const { service, bookRepo } = makeService(); + bookRepo.findLibraryIdByBookId.mockReturnValue(3); + bookRepo.findFileById.mockReturnValue({ id: 9 }); + bookRepo.findPrimaryFilesByBookIds.mockReturnValue([{ id: 1 }]); + bookRepo.findCards.mockReturnValue([{ id: 7 }]); + bookRepo.findCardsByBookIds.mockReturnValue([{ id: 8 }]); + bookRepo.findLibraryIdsByBookIds.mockReturnValue([2, 4]); + bookRepo.findRecommendationTitlesByBookIds.mockReturnValue(['Dune']); + bookRepo.findById.mockReturnValue({ id: 22 }); + + expect(service.findLibraryIdByBookId(11)).toBe(3); + expect(bookRepo.findLibraryIdByBookId).toHaveBeenCalledWith(11); + + expect(service.findFileById(9)).toEqual({ id: 9 }); + expect(bookRepo.findFileById).toHaveBeenCalledWith(9); + + expect(service.findPrimaryFilesByBookIds([1, 2])).toEqual([{ id: 1 }]); + expect(bookRepo.findPrimaryFilesByBookIds).toHaveBeenCalledWith([1, 2]); + + const where = {} as never; + const orderBy = [{} as never]; + expect(service.findCards({ where, orderBy, limit: 20, offset: 0, userId: 4 })).toEqual([{ id: 7 }]); + expect(bookRepo.findCards).toHaveBeenCalledWith({ where, orderBy, limit: 20, offset: 0, userId: 4 }); + + expect(service.findCardsByBookIds([1, 2], 4)).toEqual([{ id: 8 }]); + expect(bookRepo.findCardsByBookIds).toHaveBeenCalledWith([1, 2], 4); + + expect(service.findLibraryIdsByBookIds([10, 11])).toEqual([2, 4]); + expect(bookRepo.findLibraryIdsByBookIds).toHaveBeenCalledWith([10, 11]); + + expect(service.findRecommendationTitlesByBookIds([10])).toEqual(['Dune']); + expect(bookRepo.findRecommendationTitlesByBookIds).toHaveBeenCalledWith([10]); + + expect(service.findById(22)).toEqual({ id: 22 }); + expect(bookRepo.findById).toHaveBeenCalledWith(22); + }); + + it('delegates count and metadata updates', async () => { + const { service, bookRepo } = makeService(); + const where = {} as never; + const patch = { title: 'Updated' }; + + bookRepo.countWhere.mockResolvedValue(12); + bookRepo.updateMetadataFields.mockResolvedValue(undefined); + + await expect(service.countWhere(where)).resolves.toBe(12); + expect(bookRepo.countWhere).toHaveBeenCalledWith(where); + + await expect(service.updateMetadataFields(3, patch)).resolves.toBeUndefined(); + expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(3, patch); + }); +}); diff --git a/server/src/modules/book/book.controller.test.ts b/server/src/modules/book/book.controller.test.ts index 9085458fc..9ecdaff77 100644 --- a/server/src/modules/book/book.controller.test.ts +++ b/server/src/modules/book/book.controller.test.ts @@ -5,6 +5,7 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import type { RequestUser } from '../../common/types/request-user'; +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import { BookController } from './book.controller'; vi.mock('archiver', () => ({ @@ -124,11 +125,16 @@ function makeController() { resolveDownloadFilename: vi.fn(), getProgress: vi.fn(), getBookProgress: vi.fn(), + getAudioProgress: vi.fn(), saveProgress: vi.fn(), + saveAudioProgress: vi.fn(), updateMetadata: vi.fn(), + updateMetadataLocks: vi.fn(), refreshMetadata: vi.fn(), + getMetadataFromFile: vi.fn(), verifyBookAccess: vi.fn(), getKoboState: vi.fn(), + setReadStatus: vi.fn(), getDetail: vi.fn(), }; const fileWriteService = { @@ -158,6 +164,14 @@ describe('BookController', () => { await expect(controller.getCover(7, makeUser(), reply, undefined)).rejects.toThrow(NotFoundException); }); + it('delegates embed-all endpoint to service', async () => { + const { controller, bookService } = makeController(); + bookService.embedAll.mockResolvedValue({ queued: 5 }); + + await expect(controller.embedAll()).resolves.toEqual({ queued: 5 }); + expect(bookService.embedAll).toHaveBeenCalledTimes(1); + }); + it('returns 304 when cover etag matches', async () => { const { controller, bookService } = makeController(); const { reply, headers } = makeReply(); @@ -300,6 +314,24 @@ describe('BookController', () => { expect(raw.end).not.toHaveBeenCalled(); }); + it('streams server-sent events for bulk cover re-extraction progress', async () => { + const { controller, bookService } = makeController(); + const { reply, raw } = makeReply(); + bookService.bulkReExtractCover.mockImplementation( + (_bookIds: number[], _user: RequestUser, onProgress: (bookId: number) => void, options?: { isCancelled?: () => boolean }) => { + expect(options?.isCancelled?.()).toBe(false); + onProgress(12); + return Promise.resolve({ processed: 1, updated: 1 }); + }, + ); + + await controller.bulkReExtractCover({ bookIds: [12] }, makeUser(), reply); + + expect(raw.write).toHaveBeenNthCalledWith(1, `data: ${JSON.stringify({ bookId: 12 })}\n\n`); + expect(raw.write).toHaveBeenNthCalledWith(2, `data: ${JSON.stringify({ done: true, processed: 1, updated: 1 })}\n\n`); + expect(raw.end).toHaveBeenCalled(); + }); + it('archives exported files into a zip stream', async () => { const { controller, bookService } = makeController(); const { reply, raw } = makeReply(); @@ -328,6 +360,27 @@ describe('BookController', () => { expect(archive.finalize).toHaveBeenCalled(); }); + it('throws export errors when archive finalization fails without disconnect', async () => { + const { controller, bookService } = makeController(); + const { reply } = makeReply(); + bookService.getExportFiles.mockResolvedValue({ + files: [{ absolutePath: '/books/a.epub', zipPath: 'A.epub', sizeBytes: 10 }], + projectedBytes: 10, + bookCount: 1, + scope: 'primary', + }); + const archive = { + pipe: vi.fn(), + file: vi.fn(), + on: vi.fn().mockReturnThis(), + abort: vi.fn(), + finalize: vi.fn().mockRejectedValue(new Error('zip failure')), + }; + (archiver as unknown as vi.Mock).mockReturnValueOnce(archive); + + await expect(controller.exportBooks({ bookIds: [1], allFormats: false }, makeUser(), reply)).rejects.toThrow('zip failure'); + }); + it('parses export download query args and delegates with scope', async () => { const { controller, bookService } = makeController(); const { reply } = makeReply(); @@ -349,6 +402,7 @@ describe('BookController', () => { await expect(controller.exportBooksDownload(undefined, 'primary', makeUser(), reply)).rejects.toThrow(BadRequestException); await expect(controller.exportBooksDownload('1', 'invalid', makeUser(), reply)).rejects.toThrow(BadRequestException); + await expect(controller.exportBooksDownload('1,-2', 'primary', makeUser(), reply)).rejects.toThrow(BadRequestException); }); it('aborts archive export and swallows finalize errors after client disconnect', async () => { @@ -398,4 +452,157 @@ describe('BookController', () => { expect(bookService.verifyBookAccess).toHaveBeenCalledWith(12, expect.any(Object)); expect(result).toEqual({ entries: [{ id: 1 }] }); }); + + it('handles thumbnail 304 and streaming responses', async () => { + const { controller, bookService } = makeController(); + const first = makeReply(); + const second = makeReply(); + bookService.getThumbnailPath.mockResolvedValue('/tmp/thumb.jpg'); + mockStat.mockResolvedValue({ mtimeMs: 1000 } as never); + + await controller.getThumbnail(7, makeUser(), first.reply, '"1000"'); + expect(first.reply.status).toHaveBeenCalledWith(304); + expect(first.headers['ETag']).toBe('"1000"'); + + await controller.getThumbnail(7, makeUser(), second.reply, undefined); + expect(second.reply.type).toHaveBeenCalledWith('image/jpeg'); + expect(mockCreateReadStream).toHaveBeenCalledWith('/tmp/thumb.jpg'); + }); + + it('throws NotFoundException when thumbnail is missing', async () => { + const { controller, bookService } = makeController(); + const { reply } = makeReply(); + bookService.getThumbnailPath.mockResolvedValue(null); + + await expect(controller.getThumbnail(7, makeUser(), reply, undefined)).rejects.toThrow(NotFoundException); + }); + + it('delegates re-extract-cover endpoint to bulk cover flow', async () => { + const { controller, bookService } = makeController(); + bookService.bulkReExtractCover.mockResolvedValue({ processed: 1, updated: 1 }); + const user = makeUser(); + + await controller.reExtractCover(42, user); + + expect(bookService.bulkReExtractCover).toHaveBeenCalledWith([42], user); + }); + + it('logs and rethrows download failures', async () => { + const { controller, bookService } = makeController(); + const { reply } = makeReply(); + bookService.getFileInfo.mockRejectedValue(new Error('fs unavailable')); + + await expect(controller.downloadFile(1, makeUser(), reply)).rejects.toThrow('fs unavailable'); + }); + + it('uses fallback object when no file progress exists', async () => { + const { controller, bookService } = makeController(); + const user = makeUser(); + bookService.getProgress.mockResolvedValue(null); + + await expect(controller.getFileProgress(9, user)).resolves.toEqual({ cfi: null, pageNumber: null, percentage: 0 }); + expect(bookService.getProgress).toHaveBeenCalledWith(user.id, 9, user); + }); + + it('delegates progress mutation endpoints to service', async () => { + const { controller, bookService } = makeController(); + const user = makeUser(); + + await controller.saveFileProgress(9, { percentage: 25 } as never, user); + await controller.saveAudioProgress(11, { currentFileId: 2, positionSeconds: 90, percentage: 20 } as never, user); + + expect(bookService.saveProgress).toHaveBeenCalledWith(user.id, 9, { percentage: 25 }, user); + expect(bookService.saveAudioProgress).toHaveBeenCalledWith(user.id, 11, { currentFileId: 2, positionSeconds: 90, percentage: 20 }, user); + }); + + it('delegates remaining book endpoints to service methods', async () => { + const { controller, bookService } = makeController(); + const user = makeUser(); + bookService.updateMetadata.mockResolvedValue({ id: 7 }); + bookService.updateMetadataLocks.mockResolvedValue({ id: 7, lockedFields: ['title'] }); + bookService.refreshMetadata.mockResolvedValue({ id: 7 }); + bookService.getMetadataFromFile.mockResolvedValue({ title: 'File Title' }); + bookService.getKoboState.mockResolvedValue({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshot: null }); + bookService.getAudioProgress.mockResolvedValue({ positionSeconds: 15 }); + bookService.getDetail.mockResolvedValue({ id: 7, title: 'Detail' }); + + await controller.updateMetadata(7, { title: 'New' } as never, user); + await controller.updateMetadataLocks(7, { lockedFields: ['title'] } as never, user); + await controller.refreshMetadata(7, 'true', user); + await controller.getMetadataFromFile(7, user); + await controller.getKoboState(7, user); + await controller.setReadStatus(7, { status: 'reading' } as never, user); + await controller.getAudioProgress(7, user); + await controller.getDetail(7, user); + + expect(bookService.updateMetadata).toHaveBeenCalledWith(7, { title: 'New' }, user); + expect(bookService.updateMetadataLocks).toHaveBeenCalledWith(7, ['title'], user); + expect(bookService.refreshMetadata).toHaveBeenCalledWith(7, true, user); + expect(bookService.getMetadataFromFile).toHaveBeenCalledWith(7, user); + expect(bookService.getKoboState).toHaveBeenCalledWith(7, user); + expect(bookService.setReadStatus).toHaveBeenCalledWith(7, 'reading', user); + expect(bookService.getAudioProgress).toHaveBeenCalledWith(user.id, 7, user); + expect(bookService.getDetail).toHaveBeenCalledWith(7, user); + }); + + it('throws when writing to a closed sse stream', () => { + const { controller } = makeController(); + const { reply } = makeReply(); + const stream = (controller as any).createSseStream(reply); + + stream.close(); + + expect(() => stream.send({ ping: true })).toThrow('SSE stream closed'); + }); + + it('invokes auditable descriptions for bulk and metadata actions', () => { + const deleteAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.deleteBooks) as { + description: (req: { body: { bookIds?: number[] } }) => string; + }; + const bulkRefreshAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.bulkRefreshMetadata) as { + description: (req: { body: { bookIds?: number[] } }) => string; + }; + const bulkCoverAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.bulkReExtractCover) as { + description: (req: { body: { bookIds?: number[] } }) => string; + }; + const updateMetadataAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.updateMetadata) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }) => string; + }; + const updateLocksAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.updateMetadataLocks) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }) => string; + }; + const refreshAudit = Reflect.getMetadata(AUDITABLE_KEY, BookController.prototype.refreshMetadata) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }) => string; + }; + + expect(deleteAudit.description({ body: { bookIds: [1, 2] } })).toBe('Deleted 2 books'); + expect(bulkRefreshAudit.description({ body: { bookIds: [1] } })).toBe('Bulk refreshed metadata for 1 book'); + expect(bulkCoverAudit.description({ body: { bookIds: [1, 2, 3] } })).toBe('Bulk re-extracted covers for 3 books'); + expect(updateMetadataAudit.getResourceId({ params: { id: '44' } })).toBe(44); + expect(updateMetadataAudit.description({ params: { id: '44' } })).toBe('Updated metadata 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); + expect(refreshAudit.description({ params: { id: '44' } })).toBe('Refreshed metadata for book #44'); + }); + + it('preserves valid surrogate pairs while stripping lone surrogates in download filenames', async () => { + const { controller, bookService } = makeController(); + const { reply, headers } = makeReply(); + bookService.getFileInfo.mockResolvedValue({ + path: '/tmp/book.epub', + size: 100, + format: 'epub', + bookId: 5, + originalFilename: 'book.epub', + }); + bookService.resolveDownloadFilename.mockResolvedValue('ok-\uD83D\uDE00-\uD800.epub'); + + await controller.downloadFile(1, makeUser(), reply); + + expect(headers['Content-Disposition']).toBe(`attachment; filename="ok-__-_.epub"; filename*=UTF-8''ok-%F0%9F%98%80-.epub`); + }); }); diff --git a/server/src/modules/book/book.module.test.ts b/server/src/modules/book/book.module.test.ts index b33244680..91120f43c 100644 --- a/server/src/modules/book/book.module.test.ts +++ b/server/src/modules/book/book.module.test.ts @@ -7,6 +7,9 @@ vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} })); vi.mock('../metadata-fetch/metadata-fetch.module', () => ({ MetadataFetchModule: class MetadataFetchModule {} })); +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { LibraryModule } from '../library/library.module'; import { BookQueryBuilder } from './book-query-builder.service'; import { BookReadService } from './book-read.service'; import { BookController } from './book.controller'; @@ -20,4 +23,12 @@ describe('BookModule', () => { expect(Reflect.getMetadata('providers', BookModule)).toEqual([BookService, BookRepository, BookReadService, BookQueryBuilder]); expect(Reflect.getMetadata('exports', BookModule)).toEqual([BookService, BookReadService, BookQueryBuilder]); }); + + it('keeps library module in forwardRef imports', () => { + const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, BookModule) as Array<{ forwardRef?: () => unknown }>; + const forwardRefImport = imports.find((entry) => typeof entry?.forwardRef === 'function'); + + expect(forwardRefImport).toBeDefined(); + expect(forwardRefImport?.forwardRef?.()).toBe(LibraryModule); + }); }); diff --git a/server/src/modules/book/book.repository.test.ts b/server/src/modules/book/book.repository.test.ts index c9a3a54d5..7d2ea5aaa 100644 --- a/server/src/modules/book/book.repository.test.ts +++ b/server/src/modules/book/book.repository.test.ts @@ -20,6 +20,11 @@ function makeSelectChain(terminalMethod: string, terminalResult: T) { chain.where.mockResolvedValue(terminalResult); chain.orderBy.mockReturnValue(chain); chain.limit.mockReturnValue(chain); + } else if (terminalMethod === 'offset') { + chain.where.mockReturnValue(chain); + chain.orderBy.mockReturnValue(chain); + chain.limit.mockReturnValue(chain); + chain.offset.mockResolvedValue(terminalResult); } else if (terminalMethod === 'orderBy') { chain.where.mockReturnValue(chain); chain.orderBy.mockResolvedValue(terminalResult); @@ -34,6 +39,245 @@ function makeSelectChain(terminalMethod: string, terminalResult: T) { } describe('BookRepository', () => { + it('runs callbacks inside db transactions', async () => { + const db = { + transaction: vi.fn((callback: (tx: { id: string }) => Promise) => callback({ id: 'tx-1' })), + }; + const repo = new BookRepository(db as never); + + await expect(repo.withTransaction((tx: { id: string }) => Promise.resolve(`seen-${tx.id}`))).resolves.toBe('seen-tx-1'); + expect(db.transaction).toHaveBeenCalledTimes(1); + }); + + it('findCards loads card rows and related collections for the current user', async () => { + const rows = [{ id: 10, primaryFileId: 1001 }]; + const totals = [{ total: '1' }]; + const authorRows = [{ bookId: 10, name: 'Frank Herbert' }]; + const fileRows = [{ bookId: 10, id: 1001, format: 'epub', role: 'primary' }]; + const genreRows = [{ bookId: 10, name: 'Sci-Fi' }]; + const progressRows = [{ bookFileId: 1001, percentage: 45 }]; + const statusRows = [{ bookId: 10, status: 'reading', source: 'manual', startedAt: null, finishedAt: null, updatedAt: null }]; + + const db = { + select: vi + .fn() + .mockReturnValueOnce(makeSelectChain('offset', rows)) + .mockReturnValueOnce(makeSelectChain('where', totals)) + .mockReturnValueOnce(makeSelectChain('orderBy', authorRows)) + .mockReturnValueOnce(makeSelectChain('where', fileRows)) + .mockReturnValueOnce(makeSelectChain('where', genreRows)) + .mockReturnValueOnce(makeSelectChain('where', progressRows)) + .mockReturnValueOnce(makeSelectChain('where', statusRows)), + }; + const repo = new BookRepository(db as never); + + const result = await repo.findCards({ where: undefined as never, orderBy: [] as never, limit: 25, offset: 0, userId: 7 }); + + expect(result).toEqual({ + rows, + authorRows, + fileRows, + genreRows, + progressRows, + statusRows, + total: 1, + }); + }); + + it('findCardsByBookIds returns empty payload when no ids are requested', async () => { + const db = { select: vi.fn() }; + const repo = new BookRepository(db as never); + + await expect(repo.findCardsByBookIds([], 1)).resolves.toEqual({ + rows: [], + authorRows: [], + fileRows: [], + genreRows: [], + progressRows: [], + statusRows: [], + total: 0, + }); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('findCardsByBookIds delegates to findCards with fixed pagination bounds', async () => { + const repo = new BookRepository({} as never); + const findCardsSpy = vi.spyOn(repo, 'findCards').mockResolvedValue({} as never); + + await repo.findCardsByBookIds([10, 20], 7); + + expect(findCardsSpy).toHaveBeenCalledWith({ + where: expect.anything(), + orderBy: [], + limit: 2, + offset: 0, + userId: 7, + }); + }); + + it('findById returns null when no matching book exists', async () => { + const db = { + select: vi.fn().mockReturnValue(makeSelectChain('limit', [])), + }; + const repo = new BookRepository(db as never); + + await expect(repo.findById(99)).resolves.toBeNull(); + }); + + it('findById returns full joined payload when the book exists', async () => { + const joinedBook = [{ books: { id: 10 }, book_metadata: { title: 'Dune' }, libraries: { name: 'Main' } }]; + const authorRows = [{ id: 1, name: 'Frank Herbert', sortName: 'Herbert, Frank' }]; + const genreRows = [{ name: 'Sci-Fi' }]; + const tagRows = [{ name: 'classic' }]; + const fileRows = [ + { id: 99, format: 'epub', role: 'primary', sizeBytes: 1, absolutePath: '/books/dune.epub', createdAt: new Date(), durationSeconds: null }, + ]; + const narratorRows = [{ id: 4, name: 'Narrator', sortName: null, displayOrder: 0 }]; + const db = { + select: vi + .fn() + .mockReturnValueOnce(makeSelectChain('limit', joinedBook)) + .mockReturnValueOnce(makeSelectChain('orderBy', authorRows)) + .mockReturnValueOnce(makeSelectChain('where', genreRows)) + .mockReturnValueOnce(makeSelectChain('where', tagRows)) + .mockReturnValueOnce(makeSelectChain('orderBy', fileRows)) + .mockReturnValueOnce(makeSelectChain('orderBy', narratorRows)), + }; + const repo = new BookRepository(db as never); + + const result = await repo.findById(10); + + expect(result).toEqual({ + book: joinedBook[0], + authorRows, + genreRows, + tagRows, + fileRows, + narratorRows, + }); + }); + + it('fetches per-book and per-file progress helpers with null-safe fallbacks', async () => { + const findCollectionsChain = makeSelectChain('orderBy', [{ id: 1, name: 'Favorites' }]); + const libraryIdChain = makeSelectChain('limit', [{ libraryId: 5 }]); + const missingLibraryChain = makeSelectChain('limit', []); + const fileByIdChain = makeSelectChain('limit', [{ id: 9, absolutePath: '/books/a.epub', format: 'epub', bookId: 1, libraryId: 2 }]); + const missingFileChain = makeSelectChain('limit', []); + const progressChain = makeSelectChain('limit', [{ percentage: 12 }]); + const missingProgressChain = makeSelectChain('limit', []); + const progressByBookChain = makeSelectChain('orderBy', [{ fileId: 1, cfi: null, pageNumber: null, percentage: 0, updatedAt: null }]); + const koboReadingChain = makeSelectChain('limit', [{ createdAtKobo: null }]); + const koboSnapshotChain = makeSelectChain('limit', [{ snapshotId: 8 }]); + const koboCollectionsChain = makeSelectChain('where', [{ name: 'Sync Me' }]); + const db = { + select: vi + .fn() + .mockReturnValueOnce(findCollectionsChain) + .mockReturnValueOnce(libraryIdChain) + .mockReturnValueOnce(missingLibraryChain) + .mockReturnValueOnce(fileByIdChain) + .mockReturnValueOnce(missingFileChain) + .mockReturnValueOnce(progressChain) + .mockReturnValueOnce(missingProgressChain) + .mockReturnValueOnce(progressByBookChain) + .mockReturnValueOnce(koboReadingChain) + .mockReturnValueOnce(koboSnapshotChain) + .mockReturnValueOnce(koboCollectionsChain), + }; + const repo = new BookRepository(db as never); + + await expect(repo.findCollectionsByBookId(10, 1)).resolves.toEqual([{ id: 1, name: 'Favorites' }]); + await expect(repo.findLibraryIdByBookId(10)).resolves.toBe(5); + await expect(repo.findLibraryIdByBookId(11)).resolves.toBeNull(); + await expect(repo.findFileById(9)).resolves.toEqual({ id: 9, absolutePath: '/books/a.epub', format: 'epub', bookId: 1, libraryId: 2 }); + await expect(repo.findFileById(10)).resolves.toBeNull(); + await expect(repo.findProgress(1, 9)).resolves.toEqual({ percentage: 12 }); + await expect(repo.findProgress(1, 10)).resolves.toBeNull(); + await expect(repo.findProgressByBook(1, 10)).resolves.toEqual([{ fileId: 1, cfi: null, pageNumber: null, percentage: 0, updatedAt: null }]); + await expect(repo.findKoboReadingState(1, 10)).resolves.toEqual({ createdAtKobo: null }); + await expect(repo.findKoboSnapshotState(1, 10)).resolves.toEqual({ snapshotId: 8 }); + await expect(repo.findKoboSyncCollectionNamesForBook(1, 10)).resolves.toEqual(['Sync Me']); + }); + + it('returns empty arrays for id-list helpers when input is empty', async () => { + const db = { select: vi.fn() }; + const repo = new BookRepository(db as never); + + await expect(repo.findLibraryIdsByBookIds([])).resolves.toEqual([]); + await expect(repo.findRecommendationTitlesByBookIds([])).resolves.toEqual([]); + await expect(repo.findPrimaryFilesByBookIds([])).resolves.toEqual([]); + await expect(repo.findAllFilesByBookIds([])).resolves.toEqual([]); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('maps id-list helper rows and primary-file lookups', async () => { + const libraryRows = [{ id: 1, libraryId: 7 }]; + const recommendationRows = [{ id: 1, title: 'Dune' }]; + const allIdsRows = [{ id: 3 }, { id: 4 }]; + const primaryFileRows = [{ absolutePath: '/books/a.epub', format: 'epub' }]; + const missingPrimaryRows: unknown[] = []; + const primaryFilesByIds = [{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 10 }]; + const allFilesByIds = [{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 10, sortOrder: 0 }]; + const allIdsChain = { + from: vi.fn().mockResolvedValue(allIdsRows), + }; + const db = { + select: vi + .fn() + .mockReturnValueOnce(makeSelectChain('where', libraryRows)) + .mockReturnValueOnce(makeSelectChain('where', recommendationRows)) + .mockReturnValueOnce(makeSelectChain('orderBy', primaryFilesByIds)) + .mockReturnValueOnce(makeSelectChain('orderBy', allFilesByIds)) + .mockReturnValueOnce(allIdsChain) + .mockReturnValueOnce(makeSelectChain('limit', primaryFileRows)) + .mockReturnValueOnce(makeSelectChain('limit', missingPrimaryRows)), + }; + const repo = new BookRepository(db as never); + + await expect(repo.findLibraryIdsByBookIds([1])).resolves.toEqual(libraryRows); + await expect(repo.findRecommendationTitlesByBookIds([1])).resolves.toEqual(recommendationRows); + await expect(repo.findPrimaryFilesByBookIds([1])).resolves.toEqual(primaryFilesByIds); + await expect(repo.findAllFilesByBookIds([1])).resolves.toEqual(allFilesByIds); + await expect(repo.findAllIds()).resolves.toEqual([3, 4]); + await expect(repo.findPrimaryFile(1)).resolves.toEqual({ absolutePath: '/books/a.epub', format: 'epub' }); + await expect(repo.findPrimaryFile(2)).resolves.toBeNull(); + }); + + it('writes deletion, metadata updates, and audio progress rows', async () => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteBuilder = { where: deleteWhere }; + const updateWhere = vi.fn().mockResolvedValue(undefined); + const updateBuilder = { set: vi.fn().mockReturnValue({ where: updateWhere }) }; + const audioInsert = { + values: vi.fn().mockReturnValue({ + onConflictDoUpdate: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ bookId: 10, percentage: 33 }]), + }), + }), + }; + const audioProgressSelect = makeSelectChain('limit', [{ percentage: 22 }]); + const missingAudioProgressSelect = makeSelectChain('limit', []); + const db = { + delete: vi.fn().mockReturnValue(deleteBuilder), + update: vi.fn().mockReturnValue(updateBuilder), + insert: vi.fn().mockReturnValue(audioInsert), + select: vi.fn().mockReturnValueOnce(audioProgressSelect).mockReturnValueOnce(missingAudioProgressSelect), + }; + const repo = new BookRepository(db as never); + + await repo.deleteByIds([10, 11]); + await repo.updateMetadataFields(10, { title: 'Updated' }); + await expect(repo.findAudioProgress(1, 10)).resolves.toEqual({ percentage: 22 }); + await expect(repo.findAudioProgress(1, 11)).resolves.toBeNull(); + await expect(repo.upsertAudioProgress(1, 10, 4, 120, 33)).resolves.toEqual({ bookId: 10, percentage: 33 }); + + expect(db.delete).toHaveBeenCalledTimes(1); + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(db.update).toHaveBeenCalledTimes(1); + expect(updateWhere).toHaveBeenCalledTimes(1); + expect(db.insert).toHaveBeenCalledTimes(1); + }); + it('returns empty pattern metadata without hitting DB when no book ids are provided', async () => { const db = { select: vi.fn() }; const repo = new BookRepository(db as never); diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index 7c74b29e6..5014f544e 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -4,6 +4,11 @@ import { access, readdir, rm, stat } from 'fs/promises'; import type { RequestUser } from '../../common/types/request-user'; import { MetadataProviderKey } from '@projectx/types'; +import { extractEpubMetadata } from '../metadata/lib/epub'; +import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata'; +import { parseFb2File } from '../metadata/lib/fb2-parser'; +import { parseMobiFile } from '../metadata/lib/mobi-parser'; +import { parsePdfFile } from '../metadata/lib/pdf-parser'; import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto'; import { BookService } from './book.service'; @@ -18,10 +23,39 @@ vi.mock('fs/promises', async () => { }; }); +vi.mock('../metadata/lib/epub', () => ({ + extractEpubMetadata: vi.fn(), +})); + +vi.mock('../metadata/lib/cbz-metadata', () => ({ + extractCbzMetadata: vi.fn(), + extractCbrMetadata: vi.fn(), + extractCb7Metadata: vi.fn(), +})); + +vi.mock('../metadata/lib/fb2-parser', () => ({ + parseFb2File: vi.fn(), +})); + +vi.mock('../metadata/lib/mobi-parser', () => ({ + parseMobiFile: vi.fn(), +})); + +vi.mock('../metadata/lib/pdf-parser', () => ({ + parsePdfFile: vi.fn(), +})); + const mockAccess = access as MockedFunction; const mockReaddir = readdir as MockedFunction; const mockRm = rm as MockedFunction; const mockStat = stat as MockedFunction; +const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; +const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; +const mockExtractCbrMetadata = extractCbrMetadata as MockedFunction; +const mockExtractCb7Metadata = extractCb7Metadata as MockedFunction; +const mockParseFb2File = parseFb2File as MockedFunction; +const mockParseMobiFile = parseMobiFile as MockedFunction; +const mockParsePdfFile = parsePdfFile as MockedFunction; function makeUser(overrides?: Partial): RequestUser { return { @@ -43,11 +77,14 @@ function makeUser(overrides?: Partial): RequestUser { function makeService() { const bookRepo = { + findCards: vi.fn(), findPatternMetadataByBookIds: vi.fn(), findLibraryIdsByBookIds: vi.fn(), findPrimaryFilesByBookIds: vi.fn(), findAllFilesByBookIds: vi.fn(), + findPrimaryFile: vi.fn(), findById: vi.fn(), + findCollectionsByBookId: vi.fn(), findKoboReadingState: vi.fn(), findKoboSnapshotState: vi.fn(), findKoboSyncCollectionNamesForBook: vi.fn(), @@ -186,6 +223,13 @@ describe('BookService', () => { mockReaddir.mockReset(); mockRm.mockReset(); mockStat.mockReset(); + mockExtractEpubMetadata.mockReset(); + mockExtractCbzMetadata.mockReset(); + mockExtractCbrMetadata.mockReset(); + mockExtractCb7Metadata.mockReset(); + mockParseFb2File.mockReset(); + mockParseMobiFile.mockReset(); + mockParsePdfFile.mockReset(); }); describe('download naming', () => { @@ -1262,4 +1306,471 @@ describe('BookService', () => { expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled(); }); }); + + describe('query, pagination, and read-state delegates', () => { + it('queryForLibrary verifies access and returns paged cards', async () => { + const { service, libraryService, queryBuilder, bookRepo } = makeService(); + const user = makeUser({ id: 42 }); + queryBuilder.buildWhere.mockReturnValue('WHERE' as never); + queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never); + bookRepo.findCards.mockResolvedValue({ + rows: [ + { + id: 10, + status: 'present', + primaryFileId: 1, + folderPath: '/books/dune', + addedAt: new Date('2026-01-01T00:00:00.000Z'), + title: 'Dune', + seriesName: null, + seriesIndex: null, + publishedYear: null, + language: null, + rating: null, + }, + ], + authorRows: [{ bookId: 10, name: 'Frank Herbert' }], + fileRows: [{ bookId: 10, id: 1, format: 'epub', role: 'primary' }], + genreRows: [{ bookId: 10, name: 'Sci-Fi' }], + progressRows: [{ bookFileId: 1, percentage: 10 }], + statusRows: [], + total: 1, + }); + + const result = await service.queryForLibrary(user, 7, { + filter: null, + sort: [], + pagination: { page: 1, size: 5 }, + } as never); + + expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false); + expect(bookRepo.findCards).toHaveBeenCalledWith({ + where: 'WHERE', + orderBy: ['ORDER'], + limit: 5, + offset: 5, + userId: 42, + }); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.size).toBe(5); + expect(result.items).toHaveLength(1); + }); + + it('globalQuery throws when pagination window exceeds configured limit', async () => { + const { service } = makeService(); + + await expect( + service.globalQuery(makeUser(), { + filter: null, + sort: [], + pagination: { page: 9_999_999, size: 9_999_999 }, + } as never), + ).rejects.toThrow(BadRequestException); + }); + + it('globalQuery uses accessible libraries and assembled cards', async () => { + const { service, libraryService, queryBuilder, bookRepo } = makeService(); + const user = makeUser({ id: 7 }); + libraryService.findAll.mockResolvedValue([{ id: 3 }, { id: 4 }]); + queryBuilder.buildWhere.mockReturnValue('GLOBAL_WHERE' as never); + queryBuilder.buildOrderBy.mockReturnValue(['GLOBAL_ORDER'] as never); + bookRepo.findCards.mockResolvedValue({ + rows: [], + authorRows: [], + fileRows: [], + genreRows: [], + progressRows: [], + statusRows: [], + total: 0, + }); + + await service.globalQuery(user, { filter: null, sort: [], pagination: { page: 0, size: 10 } } as never); + + expect(queryBuilder.buildWhere).toHaveBeenCalledWith(null, { accessibleLibraryIds: [3, 4], userId: 7 }); + expect(bookRepo.findCards).toHaveBeenCalledWith({ + where: 'GLOBAL_WHERE', + orderBy: ['GLOBAL_ORDER'], + limit: 10, + offset: 0, + userId: 7, + }); + }); + + it('delegates getProgress and setReadStatus to downstream services', async () => { + const { service, bookRepo, userBookStatusService } = makeService(); + const user = makeUser({ id: 77 }); + vi.spyOn(service, 'verifyFileAccess').mockResolvedValue({ id: 1 } as never); + vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined); + bookRepo.findProgress.mockResolvedValue({ percentage: 12 }); + + await expect(service.getProgress(user.id, 1, user)).resolves.toEqual({ percentage: 12 }); + await service.setReadStatus(10, 'finished' as never, user); + + expect(bookRepo.findProgress).toHaveBeenCalledWith(77, 1); + expect(userBookStatusService.setManual).toHaveBeenCalledWith(77, 10, 'finished'); + }); + }); + + describe('embedding and failpoint behavior', () => { + it('embedAll returns queued=0 when embedder is unavailable', async () => { + const { service } = makeService(); + (service as unknown as { embedder?: unknown }).embedder = undefined; + + await expect(service.embedAll()).resolves.toEqual({ queued: 0 }); + }); + + it('runEmbeddings logs per-item failures and continues', async () => { + const { service, embedder } = makeService(); + const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation(); + embedder.embedBook.mockRejectedValueOnce(new Error('embedding failed')).mockResolvedValueOnce(undefined); + + await (service as any).runEmbeddings([1, 2]); + + expect(embedder.embedBook).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('validates and clears metadata failpoints for tests', () => { + const { service } = makeService(); + + expect(() => service.setMetadataUpdateFailpointForTests('not-a-stage' as never)).toThrow('Unknown metadata update failpoint'); + + service.setMetadataUpdateFailpointForTests('afterTagsReplace'); + expect(() => (service as any).throwIfMetadataUpdateFailpoint('afterTagsReplace')).toThrow( + 'Metadata update failpoint triggered: afterTagsReplace', + ); + + service.setMetadataUpdateFailpointForTests('afterGenresReplace'); + service.clearMetadataUpdateFailpointForTests(); + expect(() => (service as any).throwIfMetadataUpdateFailpoint('afterGenresReplace')).not.toThrow(); + }); + }); + + describe('metadata workflow error branches', () => { + it('refreshMetadata logs and rethrows provider failures', async () => { + const { service, bookRepo, pipeline } = makeService(); + const user = makeUser(); + bookRepo.findById.mockResolvedValue({ + book: { books: { id: 1, libraryId: 7 }, book_metadata: { title: 'Old', isbn13: null, isbn10: null } }, + authorRows: [{ id: 1, name: 'Author', sortName: null }], + genreRows: [], + }); + pipeline.runWithSources.mockRejectedValue(new Error('provider timeout')); + + await expect(service.refreshMetadata(1, false, user)).rejects.toThrow('provider timeout'); + }); + + it('bulkRefreshMetadata increments failed count when individual books fail', async () => { + const { service, bookRepo } = makeService(); + const user = makeUser(); + bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]); + const refreshSpy = vi + .spyOn(service, 'refreshMetadata') + .mockRejectedValueOnce(new Error('failed one')) + .mockResolvedValueOnce({ id: 2 } as never); + const onProgress = vi.fn(); + + const result = await service.bulkRefreshMetadata([1, 2], user, onProgress); + + expect(result).toEqual({ processed: 1, failed: 1 }); + expect(refreshSpy).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenCalledTimes(2); + }); + + it('bulkReExtractCover aborts early when cancellation is requested', async () => { + const { service, bookRepo } = makeService(); + const user = makeUser(); + bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]); + bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/1.epub', format: 'epub' }]); + + const result = await service.bulkReExtractCover([1], user, undefined, { isCancelled: () => true }); + + expect(result).toEqual({ processed: 0, updated: 0 }); + }); + }); + + describe('getDetail and metadata extraction', () => { + it('throws NotFoundException when detail lookup has no result', async () => { + const { service, bookRepo } = makeService(); + const user = makeUser(); + vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined); + bookRepo.findById.mockResolvedValue(null); + bookRepo.findCollectionsByBookId.mockResolvedValue([]); + + await expect(service.getDetail(9, user)).rejects.toThrow(NotFoundException); + }); + + it('maps detail payload and synthesizes audiobook chapters from file durations', async () => { + const { service, bookRepo, userBookStatusService, comicMetadataService } = makeService(); + const user = makeUser(); + vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined); + bookRepo.findById.mockResolvedValue({ + book: { + books: { + id: 9, + libraryId: 7, + primaryFileId: 100, + status: 'present', + folderPath: '/books/dune', + addedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + libraries: { name: 'Main', formatPriority: ['epub'] }, + book_metadata: { + title: 'Dune', + subtitle: null, + description: 'Epic', + isbn10: null, + isbn13: '9780441172719', + publisher: 'Ace', + publishedYear: 1965, + language: 'en', + pageCount: 412, + seriesName: 'Dune', + seriesIndex: 1, + rating: 5, + coverSource: 'custom', + lockedFields: ['title'], + googleBooksId: 'g1', + goodreadsId: null, + amazonId: null, + hardcoverId: null, + openLibraryId: null, + itunesId: null, + audibleId: null, + comicvineId: null, + chapters: null, + durationSeconds: 90, + abridged: null, + lastWrittenAt: null, + metadataScore: 80, + }, + }, + authorRows: [{ id: 1, name: 'Frank Herbert', sortName: 'Herbert, Frank' }], + genreRows: [{ name: 'Sci-Fi' }], + tagRows: [{ name: 'classic' }], + fileRows: [ + { + id: 100, + format: 'mp3', + role: 'content', + sizeBytes: 10, + absolutePath: '/audio/01-intro.mp3', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + durationSeconds: 30, + }, + { + id: 101, + format: 'm4b', + role: 'content', + sizeBytes: 20, + absolutePath: '/audio/02-main.m4b', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + durationSeconds: 60, + }, + ], + narratorRows: [{ id: 4, name: 'Narrator Name', sortName: null, displayOrder: 0 }], + }); + userBookStatusService.findOne.mockResolvedValue({ status: 'reading' }); + comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] }); + bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]); + + const result = await service.getDetail(9, user); + + expect(result.id).toBe(9); + expect(result.audioMetadata?.chapters).toEqual([ + { title: '01-intro', startMs: 0 }, + { title: '02-main', startMs: 30_000 }, + ]); + expect(result.files[0]?.role).toBe('primary'); + expect(result.collections).toEqual([{ id: 3, name: 'Favorites' }]); + expect(result.lockedFields).toEqual(['title']); + expect(result.comicMetadata).toEqual(expect.objectContaining({ issueNumber: '1', teams: ['House Atreides'] })); + }); + + it('getMetadataFromFile handles missing or unsupported primary files', async () => { + const { service, bookRepo } = makeService(); + const user = makeUser(); + vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined); + bookRepo.findPrimaryFile.mockResolvedValueOnce(null).mockResolvedValueOnce({ absolutePath: '/books/a.bin', format: null }); + + await expect(service.getMetadataFromFile(1, user)).rejects.toThrow(NotFoundException); + await expect(service.getMetadataFromFile(1, user)).resolves.toEqual({}); + }); + + it('maps epub metadata to update payload shape', async () => { + const { service, bookRepo } = makeService(); + vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined); + bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/dune.epub', format: 'epub' }); + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Dune', + subtitle: 'Book One', + description: 'Desc', + publisher: 'Ace', + publishedYear: 1965, + language: 'en', + isbn10: '0441172717', + isbn13: '9780441172719', + seriesName: 'Dune', + seriesIndex: 1, + authors: [{ name: 'Frank Herbert', sortName: null }], + tags: ['Sci-Fi'], + } as never); + + await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual( + expect.objectContaining({ + title: 'Dune', + subtitle: 'Book One', + authors: ['Frank Herbert'], + genres: ['Sci-Fi'], + }), + ); + }); + + it('maps pdf metadata and emits parser warnings', async () => { + const { service, bookRepo } = makeService(); + const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation(); + vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined); + bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/doc.pdf', format: 'pdf' }); + mockParsePdfFile.mockImplementation( + ( + _path: string, + options: { + onWarning: (warning: { + code: string; + absolutePath: string; + sizeBytes?: number; + thresholdBytes?: number; + errorClass?: string; + errorMessage?: string; + }) => void; + }, + ) => { + options.onWarning({ + code: 'buffered-large-pdf', + absolutePath: '/books/doc.pdf', + sizeBytes: 100, + thresholdBytes: 10, + }); + options.onWarning({ + code: 'parse-warning', + absolutePath: '/books/doc.pdf', + errorClass: 'ParseWarning', + errorMessage: 'metadata truncated', + }); + return { + title: 'PDF Title', + publisher: 'Pub', + pageCount: 10, + authors: [{ name: 'Author One', sortName: null }], + genres: ['Tech'], + } as never; + }, + ); + + await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual({ + title: 'PDF Title', + publisher: 'Pub', + pageCount: 10, + authors: ['Author One'], + genres: ['Tech'], + }); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('maps mobi, comic archive, and fb2 metadata formats', async () => { + const { service, bookRepo } = makeService(); + vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined); + bookRepo.findPrimaryFile + .mockResolvedValueOnce({ absolutePath: '/books/dune.mobi', format: 'mobi' }) + .mockResolvedValueOnce({ absolutePath: '/books/comic.cbr', format: 'cbr' }) + .mockResolvedValueOnce({ absolutePath: '/books/novel.fb2', format: 'fb2' }); + mockParseMobiFile.mockResolvedValue({ + title: 'Mobi Title', + description: 'Mobi Desc', + publisher: 'Mobi Pub', + publishedDate: '2001-02-03', + language: 'en', + isbn: '9781111111111', + authors: ['Mobius'], + tags: ['Adventure'], + } as never); + mockExtractCbrMetadata.mockResolvedValue({ + title: 'Comic Title', + subtitle: 'Issue', + description: 'Comic Desc', + publisher: 'Comic Pub', + publishedYear: 2012, + language: 'en', + pageCount: 40, + isbn10: null, + isbn13: null, + seriesName: 'Series', + seriesIndex: 4, + authors: [{ name: 'Writer', sortName: null }], + tags: ['Comics'], + comicMetadata: { issueNumber: '4' }, + } as never); + mockParseFb2File.mockResolvedValue({ + title: 'FB2 Title', + description: 'FB2 Desc', + publishedYear: 1999, + language: 'ru', + seriesName: 'FB2 Series', + seriesIndex: 2, + authors: ['Author'], + genres: ['Drama'], + } as never); + + await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual( + expect.objectContaining({ + title: 'Mobi Title', + publishedYear: 2001, + authors: ['Mobius'], + }), + ); + await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual( + expect.objectContaining({ + title: 'Comic Title', + comicMetadata: { issueNumber: '4' }, + }), + ); + await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual( + expect.objectContaining({ + title: 'FB2 Title', + genres: ['Drama'], + }), + ); + }); + }); + + describe('export edge cases', () => { + it('resolves missing sizeBytes from disk stat and throws when file is missing', async () => { + const { service, bookRepo, appSettings } = makeService(); + const user = makeUser(); + appSettings.getDownloadPattern.mockResolvedValue('{title}'); + bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]); + bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/missing.epub', format: 'epub', sizeBytes: null }]); + bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Missing' })]); + mockStat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + + await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow(NotFoundException); + }); + + it('sorts equal-order export files by absolute path as final tie-breaker', async () => { + const { service, bookRepo, appSettings } = makeService(); + const user = makeUser(); + appSettings.getDownloadPattern.mockResolvedValue('{originalFilename}'); + bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]); + bookRepo.findAllFilesByBookIds.mockResolvedValue([ + { bookId: 1, absolutePath: '/books/zeta.epub', format: 'epub', sizeBytes: 1, sortOrder: 0 }, + { bookId: 1, absolutePath: '/books/alpha.epub', format: 'epub', sizeBytes: 1, sortOrder: 0 }, + ]); + bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Any' })]); + + const plan = await service.getExportFiles([1], user, 'all'); + + expect(plan.files.map((file) => file.absolutePath)).toEqual(['/books/alpha.epub', '/books/zeta.epub']); + }); + }); }); diff --git a/server/src/modules/book/dto/book-detail.dto.test.ts b/server/src/modules/book/dto/book-detail.dto.test.ts new file mode 100644 index 000000000..683a5d1fa --- /dev/null +++ b/server/src/modules/book/dto/book-detail.dto.test.ts @@ -0,0 +1,41 @@ +import { AudioMetadataDto, BookDetailDto, BookFileDto } from './book-detail.dto'; + +describe('BookDetailDto', () => { + it('supports nested assignment for files and audio metadata', () => { + const file = Object.assign(new BookFileDto(), { + id: 5, + format: 'epub', + role: 'primary', + sizeBytes: 1024, + absolutePath: '/books/example.epub', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + filename: 'example.epub', + durationSeconds: null, + }); + + const audio = Object.assign(new AudioMetadataDto(), { + narrators: [{ id: 2, name: 'Narrator Name', sortName: 'Narrator Name', displayOrder: 0 }], + durationSeconds: 3600, + abridged: false, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + }); + + const detail = Object.assign(new BookDetailDto(), { + id: 10, + title: 'Sample Book', + files: [file], + audioMetadata: audio, + formatPriority: ['epub'], + authors: [{ id: 1, name: 'Author', sortName: 'Author' }], + genres: ['Sci-Fi'], + tags: ['favorite'], + lockedFields: [], + collections: [{ id: 9, name: 'Shelf' }], + }); + + expect(detail.files[0]).toBeInstanceOf(BookFileDto); + expect(detail.audioMetadata).toBeInstanceOf(AudioMetadataDto); + expect(detail.files[0].absolutePath).toBe('/books/example.epub'); + expect(detail.audioMetadata?.chapters?.[0]?.title).toBe('Chapter 1'); + }); +}); diff --git a/server/src/modules/book/dto/book-dto-validation.test.ts b/server/src/modules/book/dto/book-dto-validation.test.ts index 3f639858c..66029d3a1 100644 --- a/server/src/modules/book/dto/book-dto-validation.test.ts +++ b/server/src/modules/book/dto/book-dto-validation.test.ts @@ -11,6 +11,7 @@ import { SaveProgressDto } from './save-progress.dto'; import { SearchBooksDto } from './search-books.dto'; import { UpdateBookMetadataDto } from './update-book-metadata.dto'; import { UpdateRatingDto } from './update-rating.dto'; +import { UpsertAudioProgressDto } from './upsert-audio-progress.dto'; async function errorsFor(cls: new () => T, value: Record) { const dto = plainToInstance(cls, value); @@ -123,4 +124,14 @@ describe('Book DTO validation', () => { expect((await errorsFor(UpdateRatingDto, { rating: 0 })).length).toBeGreaterThan(0); expect((await errorsFor(UpdateRatingDto, { rating: 6 })).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); + }); }); diff --git a/server/src/modules/bookmark/bookmark-response.dto.test.ts b/server/src/modules/bookmark/bookmark-response.dto.test.ts new file mode 100644 index 000000000..3603c9a04 --- /dev/null +++ b/server/src/modules/bookmark/bookmark-response.dto.test.ts @@ -0,0 +1,40 @@ +import { BookmarkResponseDto } from './dto/bookmark-response.dto'; + +describe('BookmarkResponseDto', () => { + it('maps nullable bookmark fields from repository rows', () => { + const createdAt = new Date('2026-01-01T00:00:00Z'); + const dto = BookmarkResponseDto.from({ + id: 11, + userId: 22, + bookId: 33, + cfi: null, + title: '00:01:23', + positionSeconds: null, + createdAt, + } as never); + + expect(dto).toEqual({ + id: 11, + bookId: 33, + cfi: null, + title: '00:01:23', + positionSeconds: null, + createdAt, + }); + }); + + it('preserves provided CFI and position seconds values', () => { + const dto = BookmarkResponseDto.from({ + id: 9, + userId: 7, + bookId: 5, + cfi: 'epubcfi(/6/2)', + title: 'Chapter 1', + positionSeconds: 93.5, + createdAt: new Date('2026-02-01T00:00:00Z'), + } as never); + + expect(dto.cfi).toBe('epubcfi(/6/2)'); + expect(dto.positionSeconds).toBe(93.5); + }); +}); diff --git a/server/src/modules/bookmark/bookmark.module.test.ts b/server/src/modules/bookmark/bookmark.module.test.ts new file mode 100644 index 000000000..8f8be53d0 --- /dev/null +++ b/server/src/modules/bookmark/bookmark.module.test.ts @@ -0,0 +1,20 @@ +import 'reflect-metadata'; + +vi.mock('../book/book.module', () => ({ BookModule: class BookModule {} })); + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { BookmarkController } from './bookmark.controller'; +import { BookmarkModule } from './bookmark.module'; +import { BookmarkRepository } from './bookmark.repository'; +import { BookmarkService } from './bookmark.service'; + +describe('BookmarkModule', () => { + it('registers expected controller and providers', () => { + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, BookmarkModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, BookmarkModule) as Array; + + expect(controllers).toEqual([BookmarkController]); + expect(providers).toEqual(expect.arrayContaining([BookmarkService, BookmarkRepository])); + }); +}); diff --git a/server/src/modules/collection/collection.controller.test.ts b/server/src/modules/collection/collection.controller.test.ts index 91a156ae2..db91007c9 100644 --- a/server/src/modules/collection/collection.controller.test.ts +++ b/server/src/modules/collection/collection.controller.test.ts @@ -1,6 +1,9 @@ +import 'reflect-metadata'; + import { BadRequestException } from '@nestjs/common'; import type { RequestUser } from '../../common/types/request-user'; +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import { CollectionController } from './collection.controller'; const USER: RequestUser = { @@ -90,5 +93,66 @@ describe('CollectionController', () => { await expectBadRequest(() => controller.getBooks(10, USER, 0, 101)); expect(service.getBooks).not.toHaveBeenCalled(); }); + + it('rejects excessively deep pagination windows', async () => { + const { controller, service } = makeController(); + + await expectBadRequest(() => controller.getBooks(10, USER, 2_000_000, 100)); + expect(service.getBooks).not.toHaveBeenCalled(); + }); + }); + + describe('mutation endpoints', () => { + it('delegates create, update, remove, reorder, and membership mutations', async () => { + const { controller, service } = makeController(); + + await controller.create({ name: 'Favorites', icon: '⭐' } as any, USER); + await controller.update(11, { name: 'Updated' } as any, USER); + await controller.remove(11, USER); + await controller.reorder({ order: [{ id: 1, displayOrder: 0 }] } as any, USER); + await controller.addBooks(11, { bookIds: [7, 8] }, USER); + await controller.removeBooks(11, { bookIds: [7] }, USER); + await controller.findOne(11, USER); + + expect(service.create).toHaveBeenCalledWith({ name: 'Favorites', icon: '⭐' }, USER); + expect(service.update).toHaveBeenCalledWith(11, { name: 'Updated' }, USER); + expect(service.remove).toHaveBeenCalledWith(11, USER); + expect(service.reorder).toHaveBeenCalledWith({ order: [{ id: 1, displayOrder: 0 }] }, USER); + expect(service.addBooks).toHaveBeenCalledWith(11, { bookIds: [7, 8] }, USER); + expect(service.removeBooks).toHaveBeenCalledWith(11, { bookIds: [7] }, USER); + expect(service.findOne).toHaveBeenCalledWith(11, USER); + }); + }); + + it('exposes auditable metadata callbacks for resource ids and descriptions', () => { + const createMeta = Reflect.getMetadata(AUDITABLE_KEY, CollectionController.prototype.create) as { + description: (req: unknown, res: { name?: string }) => string; + }; + const updateMeta = Reflect.getMetadata(AUDITABLE_KEY, CollectionController.prototype.update) as { + getResourceId: (req: { params: { id: string } }) => number; + description: (req: { params: { id: string } }) => string; + }; + const removeMeta = Reflect.getMetadata(AUDITABLE_KEY, CollectionController.prototype.remove) as { + getResourceId: (req: { params: { id: string } }) => number; + description: (req: { params: { id: string } }) => string; + }; + const addBooksMeta = Reflect.getMetadata(AUDITABLE_KEY, CollectionController.prototype.addBooks) as { + getResourceId: (req: { params: { id: string } }) => number; + description: (req: { params: { id: string }; body: { bookIds?: number[] } }) => string; + }; + const removeBooksMeta = Reflect.getMetadata(AUDITABLE_KEY, CollectionController.prototype.removeBooks) as { + getResourceId: (req: { params: { id: string } }) => number; + description: (req: { params: { id: string }; body: { bookIds?: number[] } }) => string; + }; + + expect(createMeta.description({} as never, { name: 'Favorites' })).toBe("Created collection 'Favorites'"); + expect(updateMeta.getResourceId({ params: { id: '10' } })).toBe(10); + expect(updateMeta.description({ params: { id: '10' } })).toBe('Updated collection #10'); + expect(removeMeta.getResourceId({ params: { id: '10' } })).toBe(10); + expect(removeMeta.description({ params: { id: '10' } })).toBe('Deleted collection #10'); + expect(addBooksMeta.getResourceId({ params: { id: '10' } })).toBe(10); + expect(addBooksMeta.description({ params: { id: '10' }, body: { bookIds: [1, 2] } })).toBe('Added 2 books to collection #10'); + expect(removeBooksMeta.getResourceId({ params: { id: '10' } })).toBe(10); + expect(removeBooksMeta.description({ params: { id: '10' }, body: { bookIds: [1] } })).toBe('Removed 1 book from collection #10'); }); }); diff --git a/server/src/modules/collection/collection.module.test.ts b/server/src/modules/collection/collection.module.test.ts new file mode 100644 index 000000000..830c9510e --- /dev/null +++ b/server/src/modules/collection/collection.module.test.ts @@ -0,0 +1,21 @@ +import 'reflect-metadata'; + +vi.mock('../book/book.module', () => ({ BookModule: class BookModule {} })); +vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} })); + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { CollectionController } from './collection.controller'; +import { CollectionModule } from './collection.module'; +import { CollectionRepository } from './collection.repository'; +import { CollectionService } from './collection.service'; + +describe('CollectionModule', () => { + it('registers collection controller and providers', () => { + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, CollectionModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, CollectionModule) as Array; + + expect(controllers).toEqual([CollectionController]); + expect(providers).toEqual(expect.arrayContaining([CollectionService, CollectionRepository])); + }); +}); diff --git a/server/src/modules/collection/collection.repository.test.ts b/server/src/modules/collection/collection.repository.test.ts index 81d7b831a..fd6f8db38 100644 --- a/server/src/modules/collection/collection.repository.test.ts +++ b/server/src/modules/collection/collection.repository.test.ts @@ -3,7 +3,12 @@ vi.mock('drizzle-orm', () => ({ count: vi.fn(() => ({ op: 'count' })), eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })), inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })), + { + join: vi.fn((chunks: unknown[], separator: unknown) => ({ op: 'sql.join', chunks, separator })), + }, + ), })); import { CollectionRepository } from './collection.repository'; @@ -74,4 +79,52 @@ describe('CollectionRepository', () => { expect(firstOffset).toHaveBeenCalledWith(2); expect(result).toEqual({ bookIds: [7, 9], total: 2, page: 1, size: 2 }); }); + + it('findAllForUserWithMembership builds membership projection for provided book ids', async () => { + const orderBy = vi.fn().mockResolvedValue([{ id: 1, memberCount: 2 }]); + const groupBy = vi.fn().mockReturnValue({ orderBy }); + const where = vi.fn().mockReturnValue({ groupBy }); + const leftJoin = vi.fn().mockReturnValue({ where }); + const from = vi.fn().mockReturnValue({ leftJoin }); + db.select.mockReturnValueOnce({ from } as never); + + const rows = await repo.findAllForUserWithMembership(5, [100, 101]); + + expect(rows).toEqual([{ id: 1, memberCount: 2 }]); + expect(db.select).toHaveBeenCalledWith(expect.objectContaining({ memberCount: expect.anything() })); + }); + + it('addBooks and removeBooks issue membership writes with expected payloads', async () => { + const insertChain = { + values: vi.fn(), + onConflictDoNothing: vi.fn(), + returning: vi.fn(), + }; + insertChain.values.mockReturnValue(insertChain); + insertChain.onConflictDoNothing.mockReturnValue(insertChain); + insertChain.returning.mockResolvedValue([{ collectionId: 10, bookId: 1 }]); + + const deleteChain = { + where: vi.fn(), + returning: vi.fn(), + }; + deleteChain.where.mockReturnValue(deleteChain); + deleteChain.returning.mockResolvedValue([{ collectionId: 10, bookId: 1 }]); + + const localDb = { + insert: vi.fn().mockReturnValue(insertChain), + delete: vi.fn().mockReturnValue(deleteChain), + }; + const localRepo = new CollectionRepository(localDb as never); + + await localRepo.addBooks(10, [1, 2]); + await localRepo.removeBooks(10, [1]); + + expect(insertChain.values).toHaveBeenCalledWith([ + { collectionId: 10, bookId: 1 }, + { collectionId: 10, bookId: 2 }, + ]); + expect(insertChain.onConflictDoNothing).toHaveBeenCalled(); + expect(deleteChain.where).toHaveBeenCalled(); + }); }); diff --git a/server/src/modules/collection/collection.service.test.ts b/server/src/modules/collection/collection.service.test.ts index 528693f76..ded08f2b3 100644 --- a/server/src/modules/collection/collection.service.test.ts +++ b/server/src/modules/collection/collection.service.test.ts @@ -151,6 +151,50 @@ describe('CollectionService', () => { expect(collectionRepo.findById).toHaveBeenCalledTimes(2); expect(result).toEqual(hydrated); }); + + it('maps unique constraint errors to ConflictException semantics', async () => { + const { service, collectionRepo } = makeService(); + collectionRepo.findById.mockResolvedValue([makeCollection()]); + collectionRepo.update.mockRejectedValue({ code: '23505' }); + + await expect(service.update(10, { name: 'Favorites' }, makeUser())).rejects.toThrow('A collection with this name already exists'); + }); + }); + + describe('create/remove', () => { + it('creates collection for current user and returns hydrated row', async () => { + const { service, collectionRepo } = makeService(); + collectionRepo.insert.mockResolvedValue([{ id: 25 }]); + collectionRepo.findById.mockResolvedValue([makeCollection({ id: 25, name: 'New Collection' })]); + + const result = await service.create({ name: 'New Collection', icon: '⭐' } as any, makeUser({ id: 9 })); + + expect(collectionRepo.insert).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 9, + name: 'New Collection', + icon: '⭐', + syncToKobo: false, + }), + ); + expect(result).toEqual(expect.objectContaining({ id: 25, name: 'New Collection' })); + }); + + it('propagates ownership checks on remove and deletes using owner id', async () => { + const { service, collectionRepo } = makeService(); + collectionRepo.findById.mockResolvedValue([makeCollection({ id: 12, userId: 4 })]); + + await service.remove(12, makeUser({ id: 4 })); + expect(collectionRepo.delete).toHaveBeenCalledWith(12, 4); + }); + + it('rejects non-owner remove attempts', async () => { + const { service, collectionRepo } = makeService(); + collectionRepo.findById.mockResolvedValue([makeCollection({ userId: 9 })]); + + await expect(service.remove(12, makeUser({ id: 4 }))).rejects.toThrow(ForbiddenException); + expect(collectionRepo.delete).not.toHaveBeenCalled(); + }); }); describe('addBooks', () => { @@ -328,5 +372,25 @@ describe('CollectionService', () => { { id: 2, displayOrder: 3 }, ]); }); + + it('rethrows repository errors from reorder operations', async () => { + const { service, collectionRepo } = makeService(); + collectionRepo.updateDisplayOrders.mockRejectedValue(new Error('db unavailable')); + + await expect(service.reorder({ order: [{ id: 1, displayOrder: 0 }] }, makeUser({ id: 1 }))).rejects.toThrow('db unavailable'); + }); + }); + + describe('removeBooks', () => { + it('updates collection membership and returns hydrated collection', async () => { + const { service, collectionRepo } = makeService(); + collectionRepo.findById.mockResolvedValueOnce([makeCollection()]).mockResolvedValueOnce([makeCollection({ bookCount: 1 })]); + collectionRepo.removeBooks.mockResolvedValue([{ collectionId: 10, bookId: 7 }]); + + const result = await service.removeBooks(10, { bookIds: [7] }, makeUser()); + + expect(collectionRepo.removeBooks).toHaveBeenCalledWith(10, [7]); + expect(result).toEqual(expect.objectContaining({ bookCount: 1 })); + }); }); }); diff --git a/server/src/modules/cover/cover.controller.test.ts b/server/src/modules/cover/cover.controller.test.ts new file mode 100644 index 000000000..da649140a --- /dev/null +++ b/server/src/modules/cover/cover.controller.test.ts @@ -0,0 +1,83 @@ +import { BadRequestException } from '@nestjs/common'; + +import { CoverController } from './cover.controller'; + +describe('CoverController', () => { + const coverService = { + searchCovers: vi.fn(), + proxyImage: vi.fn(), + uploadCover: vi.fn(), + uploadCoverFromUrl: vi.fn(), + deleteCover: vi.fn(), + }; + + const controller = new CoverController(coverService as never); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('passes search filters to cover service', async () => { + await controller.searchCovers({ + title: 'Dune', + author: 'Frank Herbert', + isAudiobook: true, + provider: 'all', + } as never); + + expect(coverService.searchCovers).toHaveBeenCalledWith({ + title: 'Dune', + author: 'Frank Herbert', + isAudiobook: true, + provider: 'all', + }); + }); + + it('writes proxied image content to the response object', async () => { + const buffer = Buffer.from('img'); + const reply = { + type: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + coverService.proxyImage.mockResolvedValue({ buffer, contentType: 'image/jpeg' }); + + await controller.proxyImage('https://covers.example.com/dune.jpg', reply as never); + + expect(coverService.proxyImage).toHaveBeenCalledWith('https://covers.example.com/dune.jpg'); + expect(reply.type).toHaveBeenCalledWith('image/jpeg'); + expect(reply.send).toHaveBeenCalledWith(buffer); + }); + + it('rejects uploads with missing multipart file', async () => { + const req = { file: vi.fn().mockResolvedValue(null) }; + + await expect(controller.uploadCover(1, { id: 7 } as never, req as never)).rejects.toBeInstanceOf(BadRequestException); + await expect(controller.uploadCover(1, { id: 7 } as never, req as never)).rejects.toThrow('No file provided'); + }); + + it('uploads multipart image bytes through cover service', async () => { + const buffer = Buffer.from('bytes'); + const req = { + file: vi.fn().mockResolvedValue({ + mimetype: 'image/png', + toBuffer: vi.fn().mockResolvedValue(buffer), + }), + }; + const user = { id: 7 }; + + await controller.uploadCover(12, user as never, req as never); + + expect(coverService.uploadCover).toHaveBeenCalledWith(12, buffer, 'image/png', user); + }); + + it('delegates URL uploads and delete flow', async () => { + const user = { id: 9 }; + coverService.deleteCover.mockResolvedValue('custom'); + + await controller.uploadCoverFromUrl(11, { url: 'https://example.com/cover.jpg' }, user as never); + await expect(controller.deleteCover(11, user as never)).resolves.toEqual({ coverSource: 'custom' }); + + expect(coverService.uploadCoverFromUrl).toHaveBeenCalledWith(11, 'https://example.com/cover.jpg', user); + expect(coverService.deleteCover).toHaveBeenCalledWith(11, user); + }); +}); diff --git a/server/src/modules/cover/cover.module.test.ts b/server/src/modules/cover/cover.module.test.ts new file mode 100644 index 000000000..952a861f2 --- /dev/null +++ b/server/src/modules/cover/cover.module.test.ts @@ -0,0 +1,32 @@ +import 'reflect-metadata'; + +import { COVER_PROVIDERS } from './constants'; +import { CoverController } from './cover.controller'; +import { CoverModule } from './cover.module'; +import { CoverService } from './cover.service'; +import { CoverProviderRegistry } from './provider-registry'; +import { DuckDuckGoCoverProvider } from './providers/duckduckgo-cover-provider'; +import { ITunesCoverProvider } from './providers/itunes-cover-provider'; + +describe('CoverModule', () => { + it('registers expected controllers/providers/exports and cover provider factory', () => { + const controllers = Reflect.getMetadata('controllers', CoverModule) as unknown[]; + const providers = Reflect.getMetadata('providers', CoverModule) as unknown[]; + const exportsList = Reflect.getMetadata('exports', CoverModule) as unknown[]; + + expect(controllers).toEqual([CoverController]); + expect(providers).toEqual(expect.arrayContaining([DuckDuckGoCoverProvider, ITunesCoverProvider, CoverProviderRegistry, CoverService])); + expect(exportsList).toEqual([CoverService]); + + const providerFactory = providers.find((value): value is { provide: symbol; inject: unknown[]; useFactory: (...args: unknown[]) => unknown[] } => + Boolean(value && typeof value === 'object' && 'provide' in value && (value as { provide?: unknown }).provide === COVER_PROVIDERS), + ); + + expect(providerFactory).toBeDefined(); + expect(providerFactory?.inject).toEqual([DuckDuckGoCoverProvider, ITunesCoverProvider]); + + const duck = { key: 'duckduckgo' }; + const itunes = { key: 'itunes' }; + expect(providerFactory?.useFactory(duck, itunes)).toEqual([duck, itunes]); + }); +}); diff --git a/server/src/modules/cover/dto/upload-cover-from-url.dto.test.ts b/server/src/modules/cover/dto/upload-cover-from-url.dto.test.ts new file mode 100644 index 000000000..65b14086e --- /dev/null +++ b/server/src/modules/cover/dto/upload-cover-from-url.dto.test.ts @@ -0,0 +1,16 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { UploadCoverFromUrlDto } from './upload-cover-from-url.dto'; + +describe('UploadCoverFromUrlDto', () => { + it('accepts valid http/https URLs and rejects invalid protocols or malformed values', async () => { + await expect(validate(plainToInstance(UploadCoverFromUrlDto, { url: 'https://example.com/cover.jpg' }))).resolves.toHaveLength(0); + await expect(validate(plainToInstance(UploadCoverFromUrlDto, { url: 'http://example.com/cover.jpg' }))).resolves.toHaveLength(0); + + await expect(validate(plainToInstance(UploadCoverFromUrlDto, { url: 'ftp://example.com/file.jpg' }))).resolves.not.toHaveLength(0); + await expect(validate(plainToInstance(UploadCoverFromUrlDto, { url: 'not-a-url' }))).resolves.not.toHaveLength(0); + }); +}); diff --git a/server/src/modules/cover/providers/duckduckgo-cover-provider.test.ts b/server/src/modules/cover/providers/duckduckgo-cover-provider.test.ts new file mode 100644 index 000000000..c5ce0460f --- /dev/null +++ b/server/src/modules/cover/providers/duckduckgo-cover-provider.test.ts @@ -0,0 +1,118 @@ +import { Logger } from '@nestjs/common'; + +import { DuckDuckGoCoverProvider } from './duckduckgo-cover-provider'; + +function result(sourceUrl: string, width: number, height: number) { + return { + sourceUrl, + url: sourceUrl, + previewUrl: `${sourceUrl}/thumb`, + width, + height, + source: 'Web', + }; +} + +describe('DuckDuckGoCoverProvider', () => { + const originalFetch = global.fetch; + const loggerWarn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const loggerError = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + let fetchMock: ReturnType; + let provider: DuckDuckGoCoverProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new DuckDuckGoCoverProvider(); + fetchMock = vi.fn(); + global.fetch = fetchMock as never; + }); + + afterAll(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('combines site/general/fallback results with deduplication', async () => { + const performSearch = vi.spyOn(provider as any, 'performSearch'); + performSearch + .mockResolvedValueOnce([result('https://img/a', 500, 800), result('https://img/ignored-small', 200, 300)]) + .mockResolvedValueOnce([result('https://img/a', 500, 800), result('https://img/b', 520, 900)]) + .mockResolvedValueOnce([result('https://img/c', 530, 930)]); + + const results = await provider.search({ title: 'Dune', author: 'Frank Herbert' }); + + expect(performSearch).toHaveBeenNthCalledWith( + 1, + 'Dune Frank Herbert book (site:amazon.com OR site:goodreads.com)', + expect.stringContaining('Tall'), + ); + expect(performSearch).toHaveBeenNthCalledWith(2, 'Dune Frank Herbert book', expect.stringContaining('Tall')); + expect(performSearch).toHaveBeenNthCalledWith(3, 'Dune book', expect.stringContaining('Tall')); + expect(results.map((entry) => entry.sourceUrl)).toEqual(['https://img/a', 'https://img/b', 'https://img/c']); + }); + + it('filters audiobook results to near-square dimensions', async () => { + const performSearch = vi.spyOn(provider as any, 'performSearch'); + performSearch.mockResolvedValueOnce([result('https://img/square', 500, 500), result('https://img/tall', 500, 800)]).mockResolvedValueOnce([]); + + const results = await provider.search({ title: 'Dune', isAudiobook: true }); + + expect(results.map((entry) => entry.sourceUrl)).toEqual(['https://img/square']); + }); + + it('returns an empty list when internal search errors with a non-Error value', async () => { + vi.spyOn(provider as any, 'performSearch').mockRejectedValueOnce(null); + + await expect(provider.search({ title: 'Dune' })).resolves.toEqual([]); + expect(loggerError).toHaveBeenCalledWith('Error searching DuckDuckGo: null', undefined); + }); + + it('returns no results when the initial search page request fails', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + + await expect((provider as any).performSearch('query', '&iar=images')).resolves.toEqual([]); + expect(loggerWarn).toHaveBeenCalledWith('Search page fetch failed: 500'); + }); + + it('returns no results when vqd token is missing from html', async () => { + fetchMock.mockResolvedValueOnce(new Response('no token', { status: 200 })); + + await expect((provider as any).performSearch('query', '&iar=images')).resolves.toEqual([]); + expect(loggerWarn).toHaveBeenCalledWith('Could not find vqd token'); + }); + + it('returns no results when the JSON API request fails', async () => { + fetchMock + .mockResolvedValueOnce(new Response('vqd="123-456"', { status: 200 })) + .mockResolvedValueOnce(new Response('', { status: 403 })); + + await expect((provider as any).performSearch('query', '&iar=images')).resolves.toEqual([]); + expect(loggerWarn).toHaveBeenCalledWith('API fetch failed: 403'); + }); + + it('maps JSON API results and infers source labels', async () => { + fetchMock.mockResolvedValueOnce(new Response('vqd="123-456"', { status: 200 })).mockResolvedValueOnce( + new Response( + JSON.stringify({ + results: [ + { image: 'https://cdn.amazon.com/a.jpg', thumbnail: 'https://cdn.amazon.com/a_t.jpg', width: 700, height: 1000 }, + { image: 'https://www.goodreads.com/b.jpg', thumbnail: 'https://www.goodreads.com/b_t.jpg', width: 650, height: 920 }, + { image: 'https://images.google.com/c.jpg', thumbnail: 'https://images.google.com/c_t.jpg', width: 600, height: 900 }, + { image: 'not-a-valid-url', thumbnail: 'https://x.test/t.jpg', width: 500, height: 800 }, + ], + }), + { status: 200 }, + ), + ); + + const results = await (provider as any).performSearch('query', '&iar=images'); + + expect(results).toEqual([ + expect.objectContaining({ sourceUrl: 'https://cdn.amazon.com/a.jpg', source: 'Amazon' }), + expect.objectContaining({ sourceUrl: 'https://www.goodreads.com/b.jpg', source: 'Goodreads' }), + expect.objectContaining({ sourceUrl: 'https://images.google.com/c.jpg', source: 'Google' }), + expect.objectContaining({ sourceUrl: 'not-a-valid-url', source: 'Web' }), + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/src/modules/cover/providers/duckduckgo-cover-provider.ts b/server/src/modules/cover/providers/duckduckgo-cover-provider.ts index 9b1ad662c..0c81246e9 100644 --- a/server/src/modules/cover/providers/duckduckgo-cover-provider.ts +++ b/server/src/modules/cover/providers/duckduckgo-cover-provider.ts @@ -57,7 +57,9 @@ export class DuckDuckGoCoverProvider implements CoverProvider { return combined; } catch (error) { - this.logger.error(`Error searching DuckDuckGo: ${error.message}`, error.stack); + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + this.logger.error(`Error searching DuckDuckGo: ${message}`, stack); return []; } } diff --git a/server/src/modules/email/email-book-read.repository.test.ts b/server/src/modules/email/email-book-read.repository.test.ts new file mode 100644 index 000000000..0835e5ba3 --- /dev/null +++ b/server/src/modules/email/email-book-read.repository.test.ts @@ -0,0 +1,115 @@ +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })), + eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })), + sql: vi.fn((parts: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', parts, values })), +})); + +import { and, eq } from 'drizzle-orm'; + +import { authors, bookAuthors, bookFiles, bookTags, books, tags } from '../../db/schema'; +import { EmailBookReadRepository } from './email-book-read.repository'; + +function selectChain() { + const chain = { + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + innerJoin: vi.fn(), + orderBy: vi.fn(), + }; + chain.from.mockReturnValue(chain); + chain.where.mockReturnValue(chain); + chain.innerJoin.mockReturnValue(chain); + return chain; +} + +describe('EmailBookReadRepository', () => { + it('findBookById returns the first row or null when none is found', async () => { + const chain = selectChain(); + chain.limit.mockResolvedValueOnce([{ id: 7 }]).mockResolvedValueOnce([]); + const db = { select: vi.fn().mockReturnValue(chain) }; + const repo = new EmailBookReadRepository(db as never); + + await expect(repo.findBookById(7)).resolves.toEqual({ id: 7 }); + await expect(repo.findBookById(999)).resolves.toBeNull(); + + expect(eq).toHaveBeenCalledWith(books.id, 7); + expect(chain.limit).toHaveBeenCalledWith(1); + }); + + it('findBookPrimaryFileId and findFileById normalize missing records to null', async () => { + const chain = selectChain(); + chain.limit + .mockResolvedValueOnce([{ primaryFileId: 12 }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 18 }]) + .mockResolvedValueOnce([]); + const db = { select: vi.fn().mockReturnValue(chain) }; + const repo = new EmailBookReadRepository(db as never); + + await expect(repo.findBookPrimaryFileId(3)).resolves.toBe(12); + await expect(repo.findBookPrimaryFileId(33)).resolves.toBeNull(); + await expect(repo.findFileById(18)).resolves.toEqual({ id: 18 }); + await expect(repo.findFileById(999)).resolves.toBeNull(); + }); + + it('findFileForBook scopes by both file id and book id', async () => { + const chain = selectChain(); + chain.limit.mockResolvedValueOnce([{ id: 5, bookId: 3 }]).mockResolvedValueOnce([]); + const db = { select: vi.fn().mockReturnValue(chain) }; + const repo = new EmailBookReadRepository(db as never); + + await expect(repo.findFileForBook(3, 5)).resolves.toEqual({ id: 5, bookId: 3 }); + await expect(repo.findFileForBook(3, 9)).resolves.toBeNull(); + + expect(and).toHaveBeenCalledWith({ op: 'eq', left: bookFiles.id, right: 5 }, { op: 'eq', left: bookFiles.bookId, right: 3 }); + }); + + it('findFilesByBookId returns all file rows for the book', async () => { + const chain = selectChain(); + chain.where.mockResolvedValue([{ id: 1 }, { id: 2 }]); + const db = { select: vi.fn().mockReturnValue(chain) }; + const repo = new EmailBookReadRepository(db as never); + + await expect(repo.findFilesByBookId(8)).resolves.toEqual([{ id: 1 }, { id: 2 }]); + expect(chain.from).toHaveBeenCalledWith(bookFiles); + expect(eq).toHaveBeenCalledWith(bookFiles.bookId, 8); + }); + + it('findMetadataByBookId returns metadata row or null', async () => { + const chain = selectChain(); + chain.limit.mockResolvedValueOnce([{ bookId: 2, title: 'Dune' }]).mockResolvedValueOnce([]); + const db = { select: vi.fn().mockReturnValue(chain) }; + const repo = new EmailBookReadRepository(db as never); + + await expect(repo.findMetadataByBookId(2)).resolves.toEqual({ bookId: 2, title: 'Dune' }); + await expect(repo.findMetadataByBookId(22)).resolves.toBeNull(); + }); + + it('findAuthorNamesByBookId joins authors and preserves display order', async () => { + const chain = selectChain(); + chain.orderBy.mockResolvedValue([{ name: 'Frank Herbert' }]); + const db = { select: vi.fn().mockReturnValue(chain) }; + const repo = new EmailBookReadRepository(db as never); + + await expect(repo.findAuthorNamesByBookId(10)).resolves.toEqual([{ name: 'Frank Herbert' }]); + + expect(chain.from).toHaveBeenCalledWith(bookAuthors); + expect(chain.innerJoin).toHaveBeenCalledWith(authors, { op: 'eq', left: authors.id, right: bookAuthors.authorId }); + expect(chain.where).toHaveBeenCalledWith({ op: 'eq', left: bookAuthors.bookId, right: 10 }); + expect(chain.orderBy).toHaveBeenCalledWith(bookAuthors.displayOrder); + }); + + it('findTagNamesByBookId joins tags and filters by book id', async () => { + const chain = selectChain(); + chain.where.mockResolvedValue([{ name: 'Sci-Fi' }]); + const db = { select: vi.fn().mockReturnValue(chain) }; + const repo = new EmailBookReadRepository(db as never); + + await expect(repo.findTagNamesByBookId(6)).resolves.toEqual([{ name: 'Sci-Fi' }]); + + expect(chain.from).toHaveBeenCalledWith(bookTags); + expect(chain.innerJoin).toHaveBeenCalledWith(tags, { op: 'eq', left: tags.id, right: bookTags.tagId }); + expect(chain.where).toHaveBeenCalledWith({ op: 'eq', left: bookTags.bookId, right: 6 }); + }); +}); diff --git a/server/src/modules/email/email-provider.controller.test.ts b/server/src/modules/email/email-provider.controller.test.ts index 0da3f9397..1b267bff8 100644 --- a/server/src/modules/email/email-provider.controller.test.ts +++ b/server/src/modules/email/email-provider.controller.test.ts @@ -1,11 +1,12 @@ import 'reflect-metadata'; import type { RequestUser } from '../../common/types/request-user'; import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator'; -import { Permission } from '@projectx/types'; +import { AuditAction, AuditResource, Permission } from '@projectx/types'; import { EmailProviderController } from './email-provider.controller'; import type { CreateEmailProviderDto } from './dto/create-email-provider.dto'; import type { UpdateEmailProviderDto } from './dto/update-email-provider.dto'; import { EmailProviderService } from './email-provider.service'; +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; describe('EmailProviderController', () => { const user: RequestUser = { @@ -146,4 +147,58 @@ describe('EmailProviderController', () => { expect(getPermission('clearSystemProvider')).toBe(Permission.ManageEmail); }); }); + + describe('auditing metadata', () => { + function getAudit(method: keyof EmailProviderController) { + return Reflect.getMetadata(AUDITABLE_KEY, EmailProviderController.prototype[method as string]) as { + action: AuditAction; + resource: AuditResource; + getResourceId?: (req: { params: Record }) => number; + description: ((req: { params: Record }, res: unknown) => string) | string; + }; + } + + it('defines audit resource ids and descriptions for mutation routes', () => { + const createAudit = getAudit('create'); + expect(createAudit.action).toBe(AuditAction.EmailProviderCreate); + expect(createAudit.resource).toBe(AuditResource.EmailProvider); + expect((createAudit.description as (req: unknown, res: { name?: string }) => string)({} as never, { name: 'SMTP' })).toBe( + "Created email provider 'SMTP'", + ); + + const updateAudit = getAudit('update'); + expect(updateAudit.getResourceId?.({ params: { id: '12' } })).toBe(12); + expect((updateAudit.description as (req: { params: Record }, res: unknown) => string)({ params: { id: '12' } }, null)).toBe( + 'Updated email provider #12', + ); + + const deleteAudit = getAudit('remove'); + expect(deleteAudit.getResourceId?.({ params: { id: '13' } })).toBe(13); + expect((deleteAudit.description as (req: { params: Record }, res: unknown) => string)({ params: { id: '13' } }, null)).toBe( + 'Deleted email provider #13', + ); + + const setDefaultAudit = getAudit('setDefault'); + expect(setDefaultAudit.getResourceId?.({ params: { id: '7' } })).toBe(7); + expect((setDefaultAudit.description as (req: { params: Record }, res: unknown) => string)({ params: { id: '7' } }, null)).toBe( + 'Set email provider #7 as default', + ); + + const toggleAudit = getAudit('toggleShared'); + expect(toggleAudit.getResourceId?.({ params: { id: '6' } })).toBe(6); + expect((toggleAudit.description as (req: { params: Record }, res: unknown) => string)({ params: { id: '6' } }, null)).toBe( + 'Toggled sharing for email provider #6', + ); + + const setSystemAudit = getAudit('setSystemProvider'); + expect(setSystemAudit.getResourceId?.({ params: { id: '9' } })).toBe(9); + expect((setSystemAudit.description as (req: { params: Record }, res: unknown) => string)({ params: { id: '9' } }, null)).toBe( + 'Set email provider #9 as system mail provider', + ); + + const clearSystemAudit = getAudit('clearSystemProvider'); + expect(clearSystemAudit.action).toBe(AuditAction.EmailProviderClearSystem); + expect((clearSystemAudit.description as () => string)()).toBe('Cleared system mail provider'); + }); + }); }); diff --git a/server/src/modules/email/email-recipient-group.controller.test.ts b/server/src/modules/email/email-recipient-group.controller.test.ts index 5098a4502..e53f69ae1 100644 --- a/server/src/modules/email/email-recipient-group.controller.test.ts +++ b/server/src/modules/email/email-recipient-group.controller.test.ts @@ -1,3 +1,6 @@ +import { AuditAction, AuditResource } from '@projectx/types'; + +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { EmailRecipientGroupController } from './email-recipient-group.controller'; import type { AddGroupMemberDto } from './dto/add-group-member.dto'; @@ -63,4 +66,43 @@ describe('EmailRecipientGroupController', () => { expect(service.addMember).toHaveBeenCalledWith(3, 42, user); expect(service.removeMember).toHaveBeenCalledWith(3, 42, user); }); + + it('defines audit metadata for group mutations', () => { + const createAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientGroupController.prototype.create) as { + action: AuditAction; + resource: AuditResource; + description: (req: unknown, res: { name?: string }) => string; + }; + expect(createAudit.action).toBe(AuditAction.EmailRecipientGroupCreate); + expect(createAudit.resource).toBe(AuditResource.EmailRecipientGroup); + expect(createAudit.description({} as never, { name: 'Group A' })).toBe("Created email recipient group 'Group A'"); + + const updateAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientGroupController.prototype.update) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(updateAudit.getResourceId({ params: { id: '4' } })).toBe(4); + expect(updateAudit.description({ params: { id: '4' } }, null)).toBe('Updated email recipient group #4'); + + const deleteAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientGroupController.prototype.remove) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(deleteAudit.getResourceId({ params: { id: '4' } })).toBe(4); + expect(deleteAudit.description({ params: { id: '4' } }, null)).toBe('Deleted email recipient group #4'); + + const addMemberAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientGroupController.prototype.addMember) as { + getResourceId: (req: { params: Record; body: { recipientId?: number } }) => number; + description: (req: { params: Record; body: { recipientId?: number } }, res: unknown) => string; + }; + expect(addMemberAudit.getResourceId({ params: { id: '8' }, body: { recipientId: 11 } })).toBe(8); + expect(addMemberAudit.description({ params: { id: '8' }, body: { recipientId: 11 } }, null)).toBe('Added recipient #11 to group #8'); + + const removeMemberAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientGroupController.prototype.removeMember) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(removeMemberAudit.getResourceId({ params: { id: '8', recipientId: '11' } })).toBe(8); + expect(removeMemberAudit.description({ params: { id: '8', recipientId: '11' } }, null)).toBe('Removed recipient #11 from group #8'); + }); }); diff --git a/server/src/modules/email/email-recipient.controller.test.ts b/server/src/modules/email/email-recipient.controller.test.ts index 299a32f4f..39ddbae5b 100644 --- a/server/src/modules/email/email-recipient.controller.test.ts +++ b/server/src/modules/email/email-recipient.controller.test.ts @@ -1,3 +1,6 @@ +import { AuditAction, AuditResource } from '@projectx/types'; + +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { EmailRecipientController } from './email-recipient.controller'; import type { CreateEmailRecipientDto } from './dto/create-email-recipient.dto'; @@ -67,4 +70,29 @@ describe('EmailRecipientController', () => { expect(service.remove).toHaveBeenCalledWith(4, user); expect(service.setDefault).toHaveBeenCalledWith(4, user); }); + + it('defines audit metadata for recipient mutation routes', () => { + const createAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientController.prototype.create) as { + action: AuditAction; + resource: AuditResource; + description: (req: unknown, res: { email?: string }) => string; + }; + expect(createAudit.action).toBe(AuditAction.EmailRecipientCreate); + expect(createAudit.resource).toBe(AuditResource.EmailRecipient); + expect(createAudit.description({} as never, { email: 'kindle@example.com' })).toBe("Created email recipient 'kindle@example.com'"); + + const updateAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientController.prototype.update) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(updateAudit.getResourceId({ params: { id: '3' } })).toBe(3); + expect(updateAudit.description({ params: { id: '3' } }, null)).toBe('Updated email recipient #3'); + + const removeAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailRecipientController.prototype.remove) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(removeAudit.getResourceId({ params: { id: '3' } })).toBe(3); + expect(removeAudit.description({ params: { id: '3' } }, null)).toBe('Deleted email recipient #3'); + }); }); diff --git a/server/src/modules/email/email-template.controller.test.ts b/server/src/modules/email/email-template.controller.test.ts index ba5671557..5b4a3877a 100644 --- a/server/src/modules/email/email-template.controller.test.ts +++ b/server/src/modules/email/email-template.controller.test.ts @@ -1,3 +1,6 @@ +import { AuditAction, AuditResource } from '@projectx/types'; + +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { EmailTemplateController } from './email-template.controller'; import type { CreateEmailTemplateDto } from './dto/create-email-template.dto'; @@ -71,4 +74,36 @@ describe('EmailTemplateController', () => { expect(service.preview).toHaveBeenCalledWith(2, 77, null, user); }); + + it('defines audit metadata for template mutation endpoints', () => { + const createAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailTemplateController.prototype.create) as { + action: AuditAction; + resource: AuditResource; + description: (req: unknown, res: { name?: string }) => string; + }; + expect(createAudit.action).toBe(AuditAction.EmailTemplateCreate); + expect(createAudit.resource).toBe(AuditResource.EmailTemplate); + expect(createAudit.description({} as never, { name: 'Default' })).toBe("Created email template 'Default'"); + + const updateAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailTemplateController.prototype.update) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(updateAudit.getResourceId({ params: { id: '5' } })).toBe(5); + expect(updateAudit.description({ params: { id: '5' } }, null)).toBe('Updated email template #5'); + + const removeAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailTemplateController.prototype.remove) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(removeAudit.getResourceId({ params: { id: '5' } })).toBe(5); + expect(removeAudit.description({ params: { id: '5' } }, null)).toBe('Deleted email template #5'); + + const setDefaultAudit = Reflect.getMetadata(AUDITABLE_KEY, EmailTemplateController.prototype.setDefault) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, res: unknown) => string; + }; + expect(setDefaultAudit.getResourceId({ params: { id: '5' } })).toBe(5); + expect(setDefaultAudit.description({ params: { id: '5' } }, null)).toBe('Set email template #5 as default'); + }); }); diff --git a/server/src/modules/kobo/decorators/kobo-device.decorator.test.ts b/server/src/modules/kobo/decorators/kobo-device.decorator.test.ts new file mode 100644 index 000000000..32f54fe5e --- /dev/null +++ b/server/src/modules/kobo/decorators/kobo-device.decorator.test.ts @@ -0,0 +1,41 @@ +import 'reflect-metadata'; + +import type { ExecutionContext } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; + +import { KoboDevice } from './kobo-device.decorator'; + +describe('KoboDevice decorator', () => { + it('reads koboDevice context from request', () => { + const request = { + koboDevice: { + deviceId: 8, + deviceToken: 'tok-8', + userId: 3, + }, + }; + const ctx = { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; + + class TestController { + handler() {} + } + + (KoboDevice() as ParameterDecorator)(TestController.prototype, 'handler', 0); + const argsMetadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestController, 'handler') as Record< + string, + { factory: (data: unknown, context: ExecutionContext) => unknown } + >; + const factory = Object.values(argsMetadata)[0].factory; + const result = factory(undefined, ctx); + + expect(result).toEqual({ + deviceId: 8, + deviceToken: 'tok-8', + userId: 3, + }); + }); +}); diff --git a/server/src/modules/kobo/dto/kobo-dto-validation.test.ts b/server/src/modules/kobo/dto/kobo-dto-validation.test.ts new file mode 100644 index 000000000..b250928d1 --- /dev/null +++ b/server/src/modules/kobo/dto/kobo-dto-validation.test.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateDeviceDto } from './create-device.dto'; +import { RenameDeviceDto } from './rename-device.dto'; +import { UpdateSettingsDto } from './update-settings.dto'; + +async function getErrors(cls: new () => T, value: Record) { + return validate(plainToInstance(cls, value)); +} + +describe('Kobo DTO validation', () => { + it('accepts valid device names and rejects empty or oversized names', async () => { + expect((await getErrors(CreateDeviceDto, { name: 'My Kobo' })).length).toBe(0); + expect((await getErrors(RenameDeviceDto, { name: 'Renamed Kobo' })).length).toBe(0); + + expect((await getErrors(CreateDeviceDto, { name: '' })).length).toBeGreaterThan(0); + expect((await getErrors(RenameDeviceDto, { name: '' })).length).toBeGreaterThan(0); + expect((await getErrors(CreateDeviceDto, { name: 'x'.repeat(256) })).length).toBeGreaterThan(0); + }); + + it('validates threshold ranges and integer limits in update settings DTO', async () => { + expect( + ( + await getErrors(UpdateSettingsDto, { + readingThreshold: 2.5, + finishedThreshold: 95, + convertToKepub: true, + twoWayProgressSync: true, + forceEnableHyphenation: false, + kepubConversionLimitMb: 120, + }) + ).length, + ).toBe(0); + + expect((await getErrors(UpdateSettingsDto, { readingThreshold: 0.49 })).length).toBeGreaterThan(0); + expect((await getErrors(UpdateSettingsDto, { readingThreshold: 10.01 })).length).toBeGreaterThan(0); + expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 74 })).length).toBeGreaterThan(0); + expect((await getErrors(UpdateSettingsDto, { finishedThreshold: 101 })).length).toBeGreaterThan(0); + expect((await getErrors(UpdateSettingsDto, { convertToKepub: 'true' })).length).toBeGreaterThan(0); + expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 0 })).length).toBeGreaterThan(0); + expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 501 })).length).toBeGreaterThan(0); + expect((await getErrors(UpdateSettingsDto, { kepubConversionLimitMb: 10.5 })).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/kobo/guards/kobo-token.guard.test.ts b/server/src/modules/kobo/guards/kobo-token.guard.test.ts new file mode 100644 index 000000000..466b37f26 --- /dev/null +++ b/server/src/modules/kobo/guards/kobo-token.guard.test.ts @@ -0,0 +1,100 @@ +import { UnauthorizedException } from '@nestjs/common'; + +import { Permission } from '@projectx/types'; + +import { KoboTokenGuard } from './kobo-token.guard'; + +function makeContext(request: Record) { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as never; +} + +function makeDb() { + const where = vi.fn().mockResolvedValue(undefined); + const set = vi.fn().mockReturnValue({ where }); + const update = vi.fn().mockReturnValue({ set }); + + return { + query: { + koboDevices: { + findFirst: vi.fn(), + }, + }, + update, + set, + where, + }; +} + +describe('KoboTokenGuard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects requests without a device token param', async () => { + const db = makeDb(); + const guard = new KoboTokenGuard(db as never, { findByIdWithPermissions: vi.fn() } as never, { userHas: vi.fn() } as never); + + await expect(guard.canActivate(makeContext({ params: {} }))).rejects.toThrow(UnauthorizedException); + await expect(guard.canActivate(makeContext({ params: {} }))).rejects.toThrow('Missing device token'); + }); + + it('rejects unknown tokens and disabled users', async () => { + const db = makeDb(); + const userService = { findByIdWithPermissions: vi.fn() }; + const permissionService = { userHas: vi.fn() }; + const guard = new KoboTokenGuard(db as never, userService as never, permissionService as never); + const request = { params: { deviceToken: 'tok-1' } }; + + db.query.koboDevices.findFirst.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ id: 9, token: 'tok-1', userId: 7 }); + userService.findByIdWithPermissions.mockResolvedValueOnce({ id: 7, active: false }); + + await expect(guard.canActivate(makeContext(request))).rejects.toThrow('Invalid device token'); + await expect(guard.canActivate(makeContext(request))).rejects.toThrow('Account not found or disabled'); + }); + + it('rejects users whose Kobo sync permission has been revoked', async () => { + const db = makeDb(); + const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue({ id: 7, active: true }) }; + const permissionService = { userHas: vi.fn().mockReturnValue(false) }; + const guard = new KoboTokenGuard(db as never, userService as never, permissionService as never); + + db.query.koboDevices.findFirst.mockResolvedValue({ id: 9, token: 'tok-2', userId: 7 }); + + await expect(guard.canActivate(makeContext({ params: { deviceToken: 'tok-2' } }))).rejects.toThrow('Kobo sync permission revoked'); + expect(permissionService.userHas).toHaveBeenCalledWith({ id: 7, active: true }, Permission.KoboSync); + }); + + it('attaches user and device context for valid tokens', async () => { + const db = makeDb(); + const user = { id: 7, active: true, permissions: [Permission.KoboSync] }; + const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue(user) }; + const permissionService = { userHas: vi.fn().mockReturnValue(true) }; + const guard = new KoboTokenGuard(db as never, userService as never, permissionService as never); + + db.query.koboDevices.findFirst.mockResolvedValue({ id: 13, token: 'tok-13', userId: 7 }); + + const request = { params: { deviceToken: 'tok-13' } } as Record; + await expect(guard.canActivate(makeContext(request))).resolves.toBe(true); + + expect(request.user).toBe(user); + expect(request.koboDevice).toEqual({ deviceId: 13, deviceToken: 'tok-13', userId: 7 }); + expect(db.update).toHaveBeenCalled(); + expect(db.where).toHaveBeenCalled(); + }); + + it('does not fail auth when async last-seen update errors', async () => { + const db = makeDb(); + db.where.mockRejectedValueOnce(new Error('update failed')); + const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue({ id: 4, active: true }) }; + const permissionService = { userHas: vi.fn().mockReturnValue(true) }; + const guard = new KoboTokenGuard(db as never, userService as never, permissionService as never); + + db.query.koboDevices.findFirst.mockResolvedValue({ id: 2, token: 'tok', userId: 4 }); + + await expect(guard.canActivate(makeContext({ params: { deviceToken: 'tok' } }))).resolves.toBe(true); + }); +}); diff --git a/server/src/modules/kobo/kobo-auth.controller.test.ts b/server/src/modules/kobo/kobo-auth.controller.test.ts new file mode 100644 index 000000000..9ea79f1b9 --- /dev/null +++ b/server/src/modules/kobo/kobo-auth.controller.test.ts @@ -0,0 +1,38 @@ +import { KoboAuthController } from './kobo-auth.controller'; + +describe('KoboAuthController', () => { + const controller = new KoboAuthController(); + + it('authDevice returns fresh tokens and echoes UserKey when present', () => { + const result = controller.authDevice({ UserKey: 'user-key-1' }); + + expect(result.AccessToken).toEqual(expect.any(String)); + expect(result.RefreshToken).toEqual(expect.any(String)); + expect(result.TrackingId).toEqual(expect.any(String)); + expect(result.TokenType).toBe('Bearer'); + expect(result.UserKey).toBe('user-key-1'); + }); + + it('authDevice falls back to empty UserKey when missing', () => { + const result = controller.authDevice({}); + + expect(result.UserKey).toBe(''); + expect(result.RefreshToken).toEqual(expect.any(String)); + }); + + it('authRefresh preserves incoming RefreshToken when provided', () => { + const result = controller.authRefresh({ RefreshToken: 'existing-refresh' }); + + expect(result.AccessToken).toEqual(expect.any(String)); + expect(result.RefreshToken).toBe('existing-refresh'); + expect(result.TokenType).toBe('Bearer'); + expect(result.TrackingId).toEqual(expect.any(String)); + }); + + it('authRefresh generates RefreshToken when request does not provide one', () => { + const result = controller.authRefresh({}); + + expect(result.RefreshToken).toEqual(expect.any(String)); + expect(result.AccessToken).toEqual(expect.any(String)); + }); +}); diff --git a/server/src/modules/kobo/kobo-device.controller.test.ts b/server/src/modules/kobo/kobo-device.controller.test.ts new file mode 100644 index 000000000..62acc6b28 --- /dev/null +++ b/server/src/modules/kobo/kobo-device.controller.test.ts @@ -0,0 +1,81 @@ +import { KoboDeviceController } from './kobo-device.controller'; + +function makeReply() { + return { + status: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + }; +} + +describe('KoboDeviceController', () => { + const thumbnailService = { + serveThumbnail: vi.fn(), + }; + const downloadService = { + streamBook: vi.fn(), + }; + const proxyService = { + forward: vi.fn(), + }; + + const controller = new KoboDeviceController(thumbnailService as never, downloadService as never, proxyService as never); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('serves thumbnail for valid book ids across all thumbnail endpoints', async () => { + const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/12/thumbnail/300/300/false/image.jpg' }; + const reply = makeReply(); + + await controller.thumbnailSimple('12', undefined, { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never); + await controller.thumbnailFull('12', '"etag"', { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never); + await controller.thumbnailVersioned('12', undefined, { id: 5 } as never, { deviceToken: 'token' } as never, req as never, reply as never); + + expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(1, 5, 12, undefined, reply); + expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(2, 5, 12, '"etag"', reply); + expect(thumbnailService.serveThumbnail).toHaveBeenNthCalledWith(3, 5, 12, undefined, reply); + expect(proxyService.forward).not.toHaveBeenCalled(); + }); + + it('proxies thumbnail and download requests for non-numeric ids', async () => { + const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/not-a-number/download' }; + const reply = makeReply(); + proxyService.forward.mockResolvedValue(undefined); + + await controller.thumbnailSimple('abc', undefined, { id: 9 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never); + await controller.download('abc', { id: 9 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never); + + expect(proxyService.forward).toHaveBeenCalledTimes(2); + expect(proxyService.forward).toHaveBeenCalledWith(req, reply, 'dev-token'); + expect(downloadService.streamBook).not.toHaveBeenCalled(); + }); + + it('streams downloads for valid numeric ids', async () => { + const req = { method: 'GET', url: '/api/v1/kobo/token/v1/books/9/download' }; + const reply = makeReply(); + + await controller.download('9', { id: 11 } as never, { deviceToken: 'dev-token' } as never, req as never, reply as never); + + expect(downloadService.streamBook).toHaveBeenCalledWith(11, 9, reply); + }); + + it('returns static payload endpoints and analytics keys', () => { + expect(controller.affiliate()).toEqual({}); + expect(controller.remainingBookSeries()).toEqual({ TotalResultCount: 0, SearchResults: [] }); + expect(controller.productNextRead()).toEqual([]); + expect(controller.analyticsEvent()).toEqual({}); + expect(controller.getTests()).toEqual({ Result: 'Success', TestKey: expect.any(String) }); + }); + + it('forwards unmatched routes through proxy service', async () => { + const req = { method: 'PUT', url: '/api/v1/kobo/token/v1/unknown' }; + const reply = makeReply(); + + await controller.proxy({ deviceToken: 'tok-123' } as never, req as never, reply as never); + + expect(proxyService.forward).toHaveBeenCalledWith(req, reply, 'tok-123'); + }); +}); diff --git a/server/src/modules/kobo/kobo-store-resources.test.ts b/server/src/modules/kobo/kobo-store-resources.test.ts new file mode 100644 index 000000000..7ad8b2907 --- /dev/null +++ b/server/src/modules/kobo/kobo-store-resources.test.ts @@ -0,0 +1,17 @@ +import { KOBO_STORE_RESOURCES } from './kobo-store-resources'; + +describe('KOBO_STORE_RESOURCES', () => { + it('exposes core endpoints required by Kobo clients', () => { + expect(KOBO_STORE_RESOURCES.device_auth).toBe('https://storeapi.kobo.com/v1/auth/device'); + expect(KOBO_STORE_RESOURCES.device_refresh).toBe('https://storeapi.kobo.com/v1/auth/refresh'); + expect(KOBO_STORE_RESOURCES.library_sync).toBe('https://storeapi.kobo.com/v1/library/sync'); + expect(KOBO_STORE_RESOURCES.image_url_template).toBe('https://cdn.kobo.com/book-images/{ImageId}/{Width}/{Height}/false/image.jpg'); + expect(KOBO_STORE_RESOURCES.post_analytics_event).toBe('https://storeapi.kobo.com/v1/analytics/event'); + }); + + it('keeps known feature switches as explicit string flags', () => { + expect(KOBO_STORE_RESOURCES.kobo_audiobooks_enabled).toBe('True'); + expect(KOBO_STORE_RESOURCES.kobo_subscriptions_enabled).toBe('True'); + expect(KOBO_STORE_RESOURCES.kobo_onedrive_link_account_enabled).toBe('False'); + }); +}); diff --git a/server/src/modules/kobo/kobo-sync.controller.test.ts b/server/src/modules/kobo/kobo-sync.controller.test.ts new file mode 100644 index 000000000..eecbc2316 --- /dev/null +++ b/server/src/modules/kobo/kobo-sync.controller.test.ts @@ -0,0 +1,158 @@ +import { HttpStatus } from '@nestjs/common'; + +import { KoboSyncController } from './kobo-sync.controller'; + +function makeReply() { + return { + status: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + }; +} + +describe('KoboSyncController', () => { + const settingsService = { + getSettings: vi.fn(), + }; + const syncService = { + getDelta: vi.fn(), + getBookMetadata: vi.fn(), + removeBookFromSync: vi.fn(), + }; + const readingStateService = { + getRawState: vi.fn(), + upsertState: vi.fn(), + }; + const proxyService = { + forward: vi.fn(), + }; + + const controller = new KoboSyncController(settingsService as never, syncService as never, readingStateService as never, proxyService as never); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('initialization builds resource URLs from forwarded headers', () => { + const req = { + headers: { + 'x-forwarded-host': 'reader.example.com', + 'x-forwarded-proto': 'https', + host: 'localhost:3000', + }, + protocol: 'http', + hostname: 'localhost', + socket: { localPort: 3000 }, + }; + + const payload = controller.initialization({ deviceToken: 'device-token' } as never, req as never); + const resources = payload.Resources as Record; + + expect(resources.image_host).toBe('https://reader.example.com'); + expect(resources.image_url_template).toContain('/api/v1/kobo/device-token/v1/books/{ImageId}/thumbnail/{Width}/{Height}/false/image.jpg'); + expect(resources.library_sync).toBe('https://reader.example.com/api/v1/kobo/device-token/v1/library/sync'); + }); + + it('initialization appends local port when request host has no explicit port', () => { + const req = { + headers: { host: '127.0.0.1' }, + protocol: 'http', + hostname: '127.0.0.1', + socket: { localPort: 8080 }, + }; + + const payload = controller.initialization({ deviceToken: 'abc' } as never, req as never); + + expect((payload.Resources as Record).image_host).toBe('http://127.0.0.1:8080'); + }); + + it('librarySync sets response headers and sends entitlement payload', async () => { + settingsService.getSettings.mockResolvedValue({ + readingThreshold: 2.5, + finishedThreshold: 95, + twoWayProgressSync: true, + }); + syncService.getDelta.mockResolvedValue({ + entitlements: [{ NewEntitlement: { BookEntitlement: { Id: '12' } } }], + hasMore: true, + syncToken: 'SYNC-2', + }); + const req = { + headers: { host: 'kobo.local' }, + protocol: 'http', + hostname: 'kobo.local', + socket: { localPort: 3000 }, + }; + const reply = makeReply(); + + await controller.librarySync({ deviceToken: 'token-9' } as never, { id: 21 } as never, 'old-sync', req as never, reply as never); + + expect(settingsService.getSettings).toHaveBeenCalledWith(21); + expect(syncService.getDelta).toHaveBeenCalledWith(21, 'token-9', 'http://kobo.local:3000', 'old-sync', { + readingThreshold: 2.5, + finishedThreshold: 95, + twoWayProgressSync: true, + }); + expect(reply.header).toHaveBeenCalledWith('x-kobo-sync', 'continue'); + expect(reply.header).toHaveBeenCalledWith('x-kobo-synctoken', 'SYNC-2'); + expect(reply.send).toHaveBeenCalledWith([{ NewEntitlement: { BookEntitlement: { Id: '12' } } }]); + }); + + it('proxies metadata/state/delete for non-numeric book ids', async () => { + const req = { method: 'GET', url: '/api/v1/kobo/token/v1/library/abc/metadata' }; + const reply = makeReply(); + + await controller.getBookMetadata('abc', { id: 1 } as never, { deviceToken: 'token-1' } as never, req as never, reply as never); + await controller.deleteFromLibrary('abc', { id: 1 } as never, { deviceToken: 'token-1' } as never, req as never, reply as never); + await controller.getReadingState('abc', { id: 1 } as never, { deviceToken: 'token-1' } as never, req as never, reply as never); + await controller.updateReadingState('abc', {}, { id: 1 } as never, { deviceToken: 'token-1' } as never, req as never, reply as never); + + expect(proxyService.forward).toHaveBeenCalledTimes(4); + expect(proxyService.forward).toHaveBeenNthCalledWith(1, req, reply, 'token-1'); + }); + + it('serves metadata, delete ack, and reading-state payloads for valid ids', async () => { + const req = { headers: { host: 'localhost:3000' }, protocol: 'http', hostname: 'localhost', socket: { localPort: 3000 } }; + const reply = makeReply(); + syncService.getBookMetadata.mockResolvedValue([{ Title: 'Dune' }]); + readingStateService.getRawState.mockResolvedValueOnce(null).mockResolvedValueOnce({ EntitlementId: '5' }); + + await controller.getBookMetadata('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never); + await controller.deleteFromLibrary('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never); + await controller.getReadingState('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never); + await controller.getReadingState('5', { id: 4 } as never, { deviceToken: 'token-5' } as never, req as never, reply as never); + + expect(syncService.getBookMetadata).toHaveBeenCalledWith(4, 5, 'token-5', 'http://localhost:3000'); + expect(syncService.removeBookFromSync).toHaveBeenCalledWith(4, 5); + expect(reply.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(reply.send).toHaveBeenNthCalledWith(1, [{ Title: 'Dune' }]); + expect(reply.send).toHaveBeenNthCalledWith(2); + expect(reply.send).toHaveBeenNthCalledWith(3, []); + expect(reply.send).toHaveBeenNthCalledWith(4, [{ EntitlementId: '5' }]); + }); + + it('updateReadingState reads thresholds and uses first ReadingStates element when provided', async () => { + settingsService.getSettings.mockResolvedValue({ + readingThreshold: 3, + finishedThreshold: 92, + }); + readingStateService.upsertState.mockResolvedValue({ RequestResult: 'Success' }); + const reply = makeReply(); + + await controller.updateReadingState( + '77', + { + ReadingStates: [{ EntitlementId: '77', CurrentBookmark: { ProgressPercent: 56 } }], + CurrentBookmark: { ProgressPercent: 10 }, + }, + { id: 8 } as never, + { deviceToken: 'dev77' } as never, + { method: 'PUT', url: '/api/v1/kobo/dev77/v1/library/77/state' } as never, + reply as never, + ); + + expect(settingsService.getSettings).toHaveBeenCalledWith(8); + expect(readingStateService.upsertState).toHaveBeenCalledWith(8, 77, { EntitlementId: '77', CurrentBookmark: { ProgressPercent: 56 } }, 3, 92); + expect(reply.send).toHaveBeenCalledWith({ RequestResult: 'Success' }); + }); +}); diff --git a/server/src/modules/kobo/kobo-user.controller.test.ts b/server/src/modules/kobo/kobo-user.controller.test.ts new file mode 100644 index 000000000..76a7cddc4 --- /dev/null +++ b/server/src/modules/kobo/kobo-user.controller.test.ts @@ -0,0 +1,82 @@ +import 'reflect-metadata'; + +import { AuditAction } from '@projectx/types'; + +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; +import { KoboUserController } from './kobo-user.controller'; + +describe('KoboUserController', () => { + const deviceService = { + listDevices: vi.fn(), + createDevice: vi.fn(), + renameDevice: vi.fn(), + revokeDevice: vi.fn(), + }; + const settingsService = { + getSettings: vi.fn(), + updateSettings: vi.fn(), + }; + const controller = new KoboUserController(deviceService as never, settingsService as never); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('delegates device operations with current user id', async () => { + deviceService.listDevices.mockResolvedValue([{ id: 1 }]); + deviceService.createDevice.mockResolvedValue({ id: 2, name: 'Aura' }); + deviceService.renameDevice.mockResolvedValue({ id: 2, name: 'Clara' }); + deviceService.revokeDevice.mockResolvedValue(undefined); + + await expect(controller.listDevices({ id: 7 } as never)).resolves.toEqual([{ id: 1 }]); + await expect(controller.createDevice({ name: 'Aura' } as never, { id: 7 } as never)).resolves.toEqual({ id: 2, name: 'Aura' }); + await expect(controller.renameDevice(2, { name: 'Clara' } as never, { id: 7 } as never)).resolves.toEqual({ id: 2, name: 'Clara' }); + await expect(controller.revokeDevice(2, { id: 7 } as never)).resolves.toBeUndefined(); + + expect(deviceService.listDevices).toHaveBeenCalledWith(7); + expect(deviceService.createDevice).toHaveBeenCalledWith(7, 'Aura'); + expect(deviceService.renameDevice).toHaveBeenCalledWith(7, 2, 'Clara'); + expect(deviceService.revokeDevice).toHaveBeenCalledWith(7, 2); + }); + + it('delegates settings reads and writes with current user id', async () => { + settingsService.getSettings.mockResolvedValue({ readingThreshold: 1.2 }); + settingsService.updateSettings.mockResolvedValue({ readingThreshold: 2 }); + + await expect(controller.getSettings({ id: 5 } as never)).resolves.toEqual({ readingThreshold: 1.2 }); + await expect(controller.updateSettings({ readingThreshold: 2 } as never, { id: 5 } as never)).resolves.toEqual({ + readingThreshold: 2, + }); + + expect(settingsService.getSettings).toHaveBeenCalledWith(5); + expect(settingsService.updateSettings).toHaveBeenCalledWith(5, { readingThreshold: 2 }); + }); + + it('registers auditable metadata for create/rename/remove actions', () => { + const createAudit = Reflect.getMetadata(AUDITABLE_KEY, KoboUserController.prototype.createDevice) as { + action: AuditAction; + getResourceId: (_: unknown, response: { id?: number }) => number | undefined; + description: (req: { body: { name?: string } }) => string; + }; + const renameAudit = Reflect.getMetadata(AUDITABLE_KEY, KoboUserController.prototype.renameDevice) as { + action: AuditAction; + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record; body: { name?: string } }) => string; + }; + const removeAudit = Reflect.getMetadata(AUDITABLE_KEY, KoboUserController.prototype.revokeDevice) as { + action: AuditAction; + getResourceId: (req: { params: Record }) => number; + }; + + expect(createAudit.action).toBe(AuditAction.KoboDeviceRegister); + expect(createAudit.getResourceId({}, { id: 22 })).toBe(22); + expect(createAudit.description({ body: { name: 'Elipsa' } })).toContain('Elipsa'); + + expect(renameAudit.action).toBe(AuditAction.KoboDeviceRename); + expect(renameAudit.getResourceId({ params: { id: '9' } })).toBe(9); + expect(renameAudit.description({ params: { id: '9' }, body: { name: 'Libra' } })).toContain("to 'Libra'"); + + expect(removeAudit.action).toBe(AuditAction.KoboDeviceRemove); + expect(removeAudit.getResourceId({ params: { id: '44' } })).toBe(44); + }); +}); diff --git a/server/src/modules/kobo/kobo.module.test.ts b/server/src/modules/kobo/kobo.module.test.ts new file mode 100644 index 000000000..3003902c5 --- /dev/null +++ b/server/src/modules/kobo/kobo.module.test.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { KoboAuthController } from './kobo-auth.controller'; +import { KoboDeviceController } from './kobo-device.controller'; +import { KoboModule } from './kobo.module'; +import { KoboSyncController } from './kobo-sync.controller'; +import { KoboUserController } from './kobo-user.controller'; +import { KoboTokenGuard } from './guards/kobo-token.guard'; +import { KepubifyBinaryService } from './services/kepubify-binary.service'; +import { KoboBookAccessService } from './services/kobo-book-access.service'; +import { KoboDeviceService } from './services/kobo-device.service'; +import { KoboDownloadService } from './services/kobo-download.service'; +import { KoboProxyService } from './services/kobo-proxy.service'; +import { KoboReadingStateService } from './services/kobo-reading-state.service'; +import { KoboSettingsService } from './services/kobo-settings.service'; +import { KoboSyncService } from './services/kobo-sync.service'; +import { KoboThumbnailService } from './services/kobo-thumbnail.service'; + +describe('KoboModule', () => { + it('registers expected controllers and providers', () => { + const controllers = Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, KoboModule); + const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, KoboModule) as unknown[]; + + expect(controllers).toEqual([KoboUserController, KoboAuthController, KoboSyncController, KoboDeviceController]); + expect(providers).toEqual([ + KoboTokenGuard, + KepubifyBinaryService, + KoboDeviceService, + KoboSettingsService, + KoboBookAccessService, + KoboSyncService, + KoboReadingStateService, + KoboThumbnailService, + KoboDownloadService, + KoboProxyService, + ]); + }); +}); diff --git a/server/src/modules/kobo/services/kepubify-binary.service.test.ts b/server/src/modules/kobo/services/kepubify-binary.service.test.ts new file mode 100644 index 000000000..11571da95 --- /dev/null +++ b/server/src/modules/kobo/services/kepubify-binary.service.test.ts @@ -0,0 +1,106 @@ +vi.mock('fs/promises', () => ({ + chmod: vi.fn(), + mkdir: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), +})); + +import { chmod, mkdir, stat, writeFile } from 'fs/promises'; + +import { KepubifyBinaryService } from './kepubify-binary.service'; + +const chmodMock = vi.mocked(chmod); +const mkdirMock = vi.mocked(mkdir); +const statMock = vi.mocked(stat); +const writeFileMock = vi.mocked(writeFile); + +describe('KepubifyBinaryService', () => { + const config = { + get: vi.fn().mockReturnValue('/app-data'), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('detects expected binary names by platform/arch', () => { + const service = new KepubifyBinaryService(config as never); + + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + vi.spyOn(process, 'arch', 'get').mockReturnValue('arm64'); + expect((service as any).detectBinaryName()).toBe('kepubify-darwin-arm64'); + + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + vi.spyOn(process, 'arch', 'get').mockReturnValue('x64'); + expect((service as any).detectBinaryName()).toBe('kepubify-linux-64bit'); + }); + + it('throws for unsupported platforms', () => { + const service = new KepubifyBinaryService(config as never); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.spyOn(process, 'arch', 'get').mockReturnValue('x64'); + + expect(() => (service as any).detectBinaryName()).toThrow('Unsupported platform'); + }); + + it('caches computed binary path after first resolution', async () => { + const service = new KepubifyBinaryService(config as never); + const detectSpy = vi.spyOn(service as any, 'detectBinaryName').mockReturnValue('kepubify-linux-64bit'); + const ensureSpy = vi.spyOn(service as any, 'ensureBinary').mockResolvedValue(undefined); + + await expect(service.getBinaryPath()).resolves.toBe('/app-data/.tools/kepubify/kepubify-linux-64bit'); + await expect(service.getBinaryPath()).resolves.toBe('/app-data/.tools/kepubify/kepubify-linux-64bit'); + + expect(detectSpy).toHaveBeenCalledTimes(1); + expect(ensureSpy).toHaveBeenCalledTimes(1); + }); + + it('ensureBinary keeps executable bit for already-cached binaries', async () => { + const service = new KepubifyBinaryService(config as never); + statMock.mockResolvedValue({} as never); + + await expect((service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp')).resolves.toBeUndefined(); + + expect(chmodMock).toHaveBeenCalledWith('/tmp/kepubify', 0o755); + expect(mkdirMock).not.toHaveBeenCalled(); + }); + + it('downloads missing binary and writes executable file', async () => { + const service = new KepubifyBinaryService(config as never); + statMock.mockRejectedValue(new Error('missing')); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(Uint8Array.from([1, 2, 3]).buffer), + }), + ); + + await (service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp'); + + expect(mkdirMock).toHaveBeenCalledWith('/tmp', { recursive: true }); + expect(writeFileMock).toHaveBeenCalledWith('/tmp/kepubify', Buffer.from([1, 2, 3])); + expect(chmodMock).toHaveBeenCalledWith('/tmp/kepubify', 0o755); + }); + + it('throws when binary download returns non-ok status', async () => { + const service = new KepubifyBinaryService(config as never); + statMock.mockRejectedValue(new Error('missing')); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }), + ); + + await expect((service as any).ensureBinary('/tmp/kepubify', 'kepubify-linux-64bit', '/tmp')).rejects.toThrow( + 'Failed to download kepubify: HTTP 503', + ); + }); +}); diff --git a/server/src/modules/kobo/services/kobo-book-access.service.test.ts b/server/src/modules/kobo/services/kobo-book-access.service.test.ts index 97b3b7083..2971ce163 100644 --- a/server/src/modules/kobo/services/kobo-book-access.service.test.ts +++ b/server/src/modules/kobo/services/kobo-book-access.service.test.ts @@ -39,6 +39,22 @@ describe('KoboBookAccessService', () => { await expect(service.assertBookAccessible(7, 99)).rejects.toThrow(NotFoundException); }); + it('returns null accessible library ids for superusers and scoped ids for regular users', async () => { + const superuser = createService({ + user: { isSuperuser: true }, + accessRows: [{ libraryId: 10 }], + book: { id: 42, libraryId: 10 }, + }); + await expect(superuser.service.getAccessibleLibraryIds(7)).resolves.toBeNull(); + + const regular = createService({ + user: { isSuperuser: false }, + accessRows: [{ libraryId: 2 }, { libraryId: 4 }], + book: { id: 42, libraryId: 2 }, + }); + await expect(regular.service.getAccessibleLibraryIds(7)).resolves.toEqual([2, 4]); + }); + it('throws forbidden when the user lacks access to the book library', async () => { const { service } = createService({ user: { isSuperuser: false }, diff --git a/server/src/modules/kobo/services/kobo-device.service.test.ts b/server/src/modules/kobo/services/kobo-device.service.test.ts new file mode 100644 index 000000000..425978451 --- /dev/null +++ b/server/src/modules/kobo/services/kobo-device.service.test.ts @@ -0,0 +1,114 @@ +import { NotFoundException } from '@nestjs/common'; + +import { KoboDeviceService } from './kobo-device.service'; + +function makeSelectChain(orderByResult: unknown[] = [], limitResult: unknown[] = []) { + const chain = { + from: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + }; + chain.from.mockReturnValue(chain); + chain.where.mockReturnValue(chain); + chain.orderBy.mockResolvedValue(orderByResult); + chain.limit.mockResolvedValue(limitResult); + return chain; +} + +function makeDb() { + const insertReturning = vi.fn(); + const insertValues = vi.fn().mockReturnValue({ returning: insertReturning }); + const insert = vi.fn().mockReturnValue({ values: insertValues }); + + const updateReturning = vi.fn(); + const updateWhere = vi.fn().mockReturnValue({ returning: updateReturning }); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const update = vi.fn().mockReturnValue({ set: updateSet }); + + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const del = vi.fn().mockReturnValue({ where: deleteWhere }); + + return { + select: vi.fn(), + insert, + insertValues, + insertReturning, + update, + updateSet, + updateWhere, + updateReturning, + delete: del, + deleteWhere, + }; +} + +describe('KoboDeviceService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('lists devices for user ordered by creation time', async () => { + const db = makeDb(); + const rows = [{ id: 1, name: 'Aura' }]; + db.select.mockReturnValueOnce(makeSelectChain(rows)); + const service = new KoboDeviceService(db as never); + + await expect(service.listDevices(8)).resolves.toEqual(rows); + }); + + it('creates a device with hyphen-free token and returns persisted fields', async () => { + const db = makeDb(); + db.insertReturning.mockResolvedValueOnce([ + { + id: 22, + name: 'Libra', + token: 'server-generated-token', + lastSeenAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]); + const service = new KoboDeviceService(db as never); + + await expect(service.createDevice(8, 'Libra')).resolves.toEqual({ + id: 22, + name: 'Libra', + token: 'server-generated-token', + lastSeenAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }); + expect(db.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 8, + name: 'Libra', + token: expect.stringMatching(/^[0-9a-f]+$/i), + }), + ); + }); + + it('renames existing devices and throws when device is missing', async () => { + const db = makeDb(); + db.select.mockReturnValueOnce(makeSelectChain([], [])).mockReturnValueOnce(makeSelectChain([], [{ id: 5, userId: 8 }])); + db.updateReturning.mockResolvedValueOnce([{ id: 5, name: 'Clara', lastSeenAt: null, createdAt: new Date('2026-01-01T00:00:00.000Z') }]); + const service = new KoboDeviceService(db as never); + + await expect(service.renameDevice(8, 404, 'Nope')).rejects.toThrow(NotFoundException); + await expect(service.renameDevice(8, 5, 'Clara')).resolves.toEqual({ + id: 5, + name: 'Clara', + lastSeenAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }); + }); + + it('revokes devices and rejects unknown ids', async () => { + const db = makeDb(); + db.select.mockReturnValueOnce(makeSelectChain([], [])).mockReturnValueOnce(makeSelectChain([], [{ id: 7 }])); + const service = new KoboDeviceService(db as never); + + await expect(service.revokeDevice(8, 111)).rejects.toThrow(NotFoundException); + await expect(service.revokeDevice(8, 7)).resolves.toBeUndefined(); + expect(db.delete).toHaveBeenCalled(); + expect(db.deleteWhere).toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/kobo/services/kobo-device.service.ts b/server/src/modules/kobo/services/kobo-device.service.ts index 4b63d6485..b94c4d8ea 100644 --- a/server/src/modules/kobo/services/kobo-device.service.ts +++ b/server/src/modules/kobo/services/kobo-device.service.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { and, eq } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -46,7 +46,6 @@ export class KoboDeviceService { .limit(1); if (!device) throw new NotFoundException('Device not found'); - if (device.userId !== userId) throw new ForbiddenException('Not your device'); const [updated] = await this.db .update(schema.koboDevices) diff --git a/server/src/modules/kobo/services/kobo-download.service.test.ts b/server/src/modules/kobo/services/kobo-download.service.test.ts new file mode 100644 index 000000000..d6333ee2a --- /dev/null +++ b/server/src/modules/kobo/services/kobo-download.service.test.ts @@ -0,0 +1,243 @@ +vi.mock('fs/promises', () => ({ + mkdir: vi.fn(), + stat: vi.fn(), +})); + +vi.mock('fs', () => ({ + createReadStream: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +import { NotFoundException } from '@nestjs/common'; +import { execFile } from 'child_process'; +import { createReadStream } from 'fs'; +import { mkdir, stat } from 'fs/promises'; + +import { KoboDownloadService } from './kobo-download.service'; + +const statMock = vi.mocked(stat); +const mkdirMock = vi.mocked(mkdir); +const createReadStreamMock = vi.mocked(createReadStream); +const execFileMock = vi.mocked(execFile); + +function makeReply() { + return { + header: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + }; +} + +function makeDeps() { + return { + db: { + query: { + books: { findFirst: vi.fn() }, + bookFiles: { findFirst: vi.fn() }, + }, + }, + config: { get: vi.fn().mockReturnValue('/app-data') }, + kepubifyBinaryService: { getBinaryPath: vi.fn() }, + settingsService: { getSettings: vi.fn() }, + bookAccessService: { assertBookAccessible: vi.fn() }, + }; +} + +describe('KoboDownloadService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws when target book does not exist', async () => { + const deps = makeDeps(); + deps.db.query.books.findFirst.mockResolvedValue(null); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + + await expect(service.streamBook(7, 11, makeReply() as never)).rejects.toThrow(NotFoundException); + }); + + it('throws when book file is missing after access check', async () => { + const deps = makeDeps(); + deps.db.query.books.findFirst.mockResolvedValue({ id: 11, primaryFileId: 22 }); + deps.db.query.bookFiles.findFirst.mockResolvedValue(null); + deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + + await expect(service.streamBook(7, 11, makeReply() as never)).rejects.toThrow('No file found for this book'); + expect(deps.bookAccessService.assertBookAccessible).toHaveBeenCalledWith(7, 11); + }); + + it('uses direct file streaming for pdf files', async () => { + const deps = makeDeps(); + deps.db.query.books.findFirst.mockResolvedValue({ id: 11, primaryFileId: 22 }); + deps.db.query.bookFiles.findFirst.mockResolvedValue({ + id: 22, + format: 'pdf', + absolutePath: '/books/file.pdf', + hash: 'hash', + sizeBytes: 100, + }); + deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined); + + await service.streamBook(7, 11, makeReply() as never); + + expect(streamFileSpy).toHaveBeenCalledWith('/books/file.pdf', 22, 'pdf', expect.anything()); + }); + + it('converts epub to kepub when enabled and within conversion size limit', async () => { + const deps = makeDeps(); + deps.db.query.books.findFirst.mockResolvedValue({ id: 11, primaryFileId: 22 }); + deps.db.query.bookFiles.findFirst.mockResolvedValue({ + id: 22, + format: 'epub', + absolutePath: '/books/file.epub', + hash: 'h1', + sizeBytes: 5 * 1024 * 1024, + }); + deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined); + deps.settingsService.getSettings.mockResolvedValue({ + convertToKepub: true, + forceEnableHyphenation: true, + kepubConversionLimitMb: 10, + }); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + const streamKepubSpy = vi.spyOn(service as any, 'streamKepub').mockResolvedValue(undefined); + + await service.streamBook(7, 11, makeReply() as never); + + expect(streamKepubSpy).toHaveBeenCalledWith('/books/file.epub', 'h1', 11, 22, true, expect.anything()); + }); + + it('falls back to epub stream when conversion is disabled or over limit', async () => { + const deps = makeDeps(); + deps.db.query.books.findFirst.mockResolvedValue({ id: 11, primaryFileId: 22 }); + deps.db.query.bookFiles.findFirst.mockResolvedValue({ + id: 22, + format: 'epub', + absolutePath: '/books/file.epub', + hash: null, + sizeBytes: 20 * 1024 * 1024, + }); + deps.bookAccessService.assertBookAccessible.mockResolvedValue(undefined); + deps.settingsService.getSettings.mockResolvedValue({ + convertToKepub: true, + forceEnableHyphenation: false, + kepubConversionLimitMb: 10, + }); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined); + + await service.streamBook(7, 11, makeReply() as never); + + expect(streamFileSpy).toHaveBeenCalledWith('/books/file.epub', 22, 'epub', expect.anything()); + }); + + it('streamFile writes headers and stream payload and throws when source path is missing', async () => { + const deps = makeDeps(); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + const reply = makeReply(); + const stream = {} as never; + statMock.mockResolvedValueOnce({ size: 1234 } as never); + createReadStreamMock.mockReturnValue(stream); + + await (service as any).streamFile('/books/book.epub', 99, 'epub', reply); + + expect(reply.header).toHaveBeenCalledWith('Content-Length', 1234); + expect(reply.header).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="book-99.epub"'); + expect(reply.type).toHaveBeenCalledWith('application/epub+zip'); + expect(reply.send).toHaveBeenCalledWith(stream); + + statMock.mockRejectedValueOnce(new Error('missing')); + await expect((service as any).streamFile('/books/missing.epub', 99, 'epub', reply)).rejects.toThrow(NotFoundException); + }); + + it('streamKepub serves cached files when present', async () => { + const deps = makeDeps(); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + statMock.mockResolvedValueOnce({} as never); + const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined); + + await (service as any).streamKepub('/books/source.epub', 'abc', 44, 55, false, makeReply()); + + expect(streamFileSpy).toHaveBeenCalledWith('/app-data/.kepub-cache/44/abc.kepub.epub', 55, 'kepub.epub', expect.anything()); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it('streamKepub converts and caches on miss, then falls back when conversion fails', async () => { + const deps = makeDeps(); + deps.kepubifyBinaryService.getBinaryPath.mockResolvedValue('/tools/kepubify'); + const service = new KoboDownloadService( + deps.db as never, + deps.config as never, + deps.kepubifyBinaryService as never, + deps.settingsService as never, + deps.bookAccessService as never, + ); + const streamFileSpy = vi.spyOn(service as any, 'streamFile').mockResolvedValue(undefined); + + statMock.mockRejectedValueOnce(new Error('cache miss')); + execFileMock.mockImplementation((_path, _args, cb) => cb?.(null, '', '')); + + await (service as any).streamKepub('/books/source.epub', 'hash', 44, 55, true, makeReply()); + + expect(mkdirMock).toHaveBeenCalledWith('/app-data/.kepub-cache/44', { recursive: true }); + expect(execFileMock).toHaveBeenCalledWith( + '/tools/kepubify', + ['--hyphenate', '--output', '/app-data/.kepub-cache/44/hash-hyph.kepub.epub', '/books/source.epub'], + expect.any(Function), + ); + expect(streamFileSpy).toHaveBeenCalledWith('/app-data/.kepub-cache/44/hash-hyph.kepub.epub', 55, 'kepub.epub', expect.anything()); + + statMock.mockRejectedValueOnce(new Error('cache miss')); + execFileMock.mockImplementationOnce((_path, _args, cb) => cb?.(new Error('convert failed'))); + await (service as any).streamKepub('/books/source.epub', 'hash', 44, 55, false, makeReply()); + expect(streamFileSpy).toHaveBeenLastCalledWith('/books/source.epub', 55, 'epub', expect.anything()); + }); +}); diff --git a/server/src/modules/kobo/services/kobo-proxy.service.test.ts b/server/src/modules/kobo/services/kobo-proxy.service.test.ts new file mode 100644 index 000000000..53adb9dfd --- /dev/null +++ b/server/src/modules/kobo/services/kobo-proxy.service.test.ts @@ -0,0 +1,109 @@ +import { KoboProxyService } from './kobo-proxy.service'; + +function makeReply() { + return { + status: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + }; +} + +describe('KoboProxyService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('forwards request to Kobo API, remaps path, and relays response body and safe headers', async () => { + const service = new KoboProxyService(); + const upstreamHeaders = new Headers({ + 'content-type': 'application/json', + 'x-custom': 'ok', + connection: 'close', + 'content-length': '123', + }); + const upstream = { + status: 200, + headers: upstreamHeaders, + arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('{"ok":true}').buffer), + }; + const fetchMock = vi.fn().mockResolvedValue(upstream); + vi.stubGlobal('fetch', fetchMock); + + const req = { + method: 'POST', + url: '/api/v1/kobo/device-1/v1/library/sync?since=1', + headers: { + accept: 'application/json', + host: 'localhost:3000', + 'x-kobo-deviceid': 'dev123', + }, + body: { hello: 'world' }, + }; + const reply = makeReply(); + + await service.forward(req as never, reply as never, 'device-1'); + + expect(fetchMock).toHaveBeenCalledWith('https://storeapi.kobo.com/v1/library/sync?since=1', { + method: 'POST', + headers: { + accept: 'application/json', + 'x-kobo-deviceid': 'dev123', + }, + body: '{"hello":"world"}', + }); + expect(reply.status).toHaveBeenCalledWith(200); + expect(reply.header).toHaveBeenCalledWith('content-type', 'application/json'); + expect(reply.header).toHaveBeenCalledWith('x-custom', 'ok'); + expect(reply.header).not.toHaveBeenCalledWith('connection', 'close'); + expect(reply.header).not.toHaveBeenCalledWith('content-length', '123'); + expect(reply.send).toHaveBeenCalledWith(Buffer.from('{"ok":true}')); + }); + + it('skips body forwarding for GET requests', async () => { + const service = new KoboProxyService(); + const upstream = { + status: 204, + headers: new Headers(), + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + }; + const fetchMock = vi.fn().mockResolvedValue(upstream); + vi.stubGlobal('fetch', fetchMock); + + await service.forward( + { + method: 'GET', + url: '/v1/affiliate', + headers: {}, + body: { ignored: true }, + } as never, + makeReply() as never, + 'token', + ); + + expect(fetchMock).toHaveBeenCalledWith('https://storeapi.kobo.com/v1/affiliate', expect.objectContaining({ method: 'GET', body: undefined })); + }); + + it('returns 502 when upstream call fails', async () => { + const service = new KoboProxyService(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); + const reply = makeReply(); + + await service.forward( + { + method: 'GET', + url: '/api/v1/kobo/dev/v1/library/sync', + headers: {}, + } as never, + reply as never, + 'dev', + ); + + expect(reply.status).toHaveBeenCalledWith(502); + expect(reply.send).toHaveBeenCalledWith({ message: 'Upstream Kobo API unavailable' }); + }); +}); diff --git a/server/src/modules/kobo/services/kobo-reading-state.service.test.ts b/server/src/modules/kobo/services/kobo-reading-state.service.test.ts new file mode 100644 index 000000000..3c91df33a --- /dev/null +++ b/server/src/modules/kobo/services/kobo-reading-state.service.test.ts @@ -0,0 +1,209 @@ +import { KoboReadingStateService } from './kobo-reading-state.service'; + +function makeSelectChain(limitResult: unknown[]) { + const chain = { + from: vi.fn(), + innerJoin: vi.fn(), + leftJoin: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + }; + chain.from.mockReturnValue(chain); + chain.innerJoin.mockReturnValue(chain); + chain.leftJoin.mockReturnValue(chain); + chain.where.mockReturnValue(chain); + chain.orderBy.mockReturnValue(chain); + chain.limit.mockResolvedValue(limitResult); + return chain; +} + +function makeInsertChain() { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + return { + values, + onConflictDoUpdate, + }; +} + +function makeDb() { + return { + query: { + books: { findFirst: vi.fn() }, + bookFiles: { findFirst: vi.fn() }, + koboReadingStates: { findFirst: vi.fn() }, + }, + select: vi.fn(), + insert: vi.fn(), + }; +} + +describe('KoboReadingStateService', () => { + const bookAccessService = { + assertBookAccessible: vi.fn(), + getAccessibleLibraryIds: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + bookAccessService.assertBookAccessible.mockResolvedValue(undefined); + bookAccessService.getAccessibleLibraryIds.mockResolvedValue(null); + }); + + it('returns ignored update results when the target book is missing', async () => { + const db = makeDb(); + db.query.books.findFirst.mockResolvedValue(null); + const service = new KoboReadingStateService(db as never, bookAccessService as never); + + await expect(service.upsertState(7, 99, {}, 2, 90)).resolves.toEqual({ + RequestResult: 'Success', + UpdateResults: [ + { + EntitlementId: '99', + CurrentBookmarkResult: { Result: 'Ignored' }, + StatisticsResult: { Result: 'Ignored' }, + StatusInfoResult: { Result: 'Ignored' }, + }, + ], + }); + }); + + it('merges reading state sub-objects by LastModified and syncs progress', async () => { + const db = makeDb(); + const stateInsert = makeInsertChain(); + const progressInsert = makeInsertChain(); + db.insert.mockReturnValueOnce(stateInsert).mockReturnValueOnce(progressInsert); + db.query.books.findFirst.mockResolvedValueOnce({ id: 12 }).mockResolvedValueOnce({ primaryFileId: 88 }); + db.query.koboReadingStates.findFirst + .mockResolvedValueOnce({ + currentBookmark: { LastModified: '2026-01-02T00:00:00.000Z', ProgressPercent: 34 }, + statistics: { LastModified: '2026-01-01T00:00:00.000Z', Value: 1 }, + statusInfo: { LastModified: '2026-01-01T00:00:00.000Z', Status: 'Reading' }, + }) + .mockResolvedValueOnce({ + entitlementId: '12', + createdAtKobo: '2026-01-01T00:00:00.000Z', + lastModifiedKobo: '2026-01-03T00:00:00.000Z', + priorityTimestamp: '2026-01-03T00:00:00.000Z', + currentBookmark: { ProgressPercent: 34 }, + statistics: { Value: 1 }, + statusInfo: { Status: 'Reading' }, + }); + db.query.bookFiles.findFirst.mockResolvedValue({ id: 88 }); + + const service = new KoboReadingStateService(db as never, bookAccessService as never); + const result = await service.upsertState( + 3, + 12, + { + LastModified: '2026-01-03T00:00:00.000Z', + CurrentBookmark: { LastModified: '2026-01-01T00:00:00.000Z', ProgressPercent: 10 }, + Statistics: { LastModified: '2026-01-05T00:00:00.000Z', Value: 2 }, + }, + 2, + 90, + ); + + expect(stateInsert.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 3, + bookId: 12, + currentBookmark: { LastModified: '2026-01-02T00:00:00.000Z', ProgressPercent: 34 }, + statistics: { LastModified: '2026-01-05T00:00:00.000Z', Value: 2 }, + }), + ); + expect(progressInsert.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 3, + bookFileId: 88, + percentage: 34, + }), + ); + expect(result).toEqual({ + EntitlementId: '12', + Created: '2026-01-01T00:00:00.000Z', + LastModified: '2026-01-03T00:00:00.000Z', + PriorityTimestamp: '2026-01-03T00:00:00.000Z', + CurrentBookmark: { ProgressPercent: 34 }, + Statistics: { Value: 1 }, + StatusInfo: { Status: 'Reading' }, + }); + }); + + it('syncToReadingProgress normalizes finished progress to 100 and skips below-threshold updates', async () => { + const db = makeDb(); + const progressInsert = makeInsertChain(); + db.insert.mockReturnValue(progressInsert); + db.query.books.findFirst.mockResolvedValue({ primaryFileId: 45 }); + db.query.bookFiles.findFirst.mockResolvedValue({ id: 45 }); + const service = new KoboReadingStateService(db as never, bookAccessService as never); + + await (service as any).syncToReadingProgress(6, 7, 99, 2, 90); + expect(progressInsert.values).toHaveBeenCalledWith(expect.objectContaining({ percentage: 100 })); + + progressInsert.values.mockClear(); + await (service as any).syncToReadingProgress(6, 7, 1, 2, 90); + expect(progressInsert.values).not.toHaveBeenCalled(); + }); + + it('getRawState returns null when absent and maps persisted fields when present', async () => { + const db = makeDb(); + db.query.koboReadingStates.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ + entitlementId: '44', + createdAtKobo: '2026-01-01T00:00:00.000Z', + lastModifiedKobo: '2026-01-02T00:00:00.000Z', + priorityTimestamp: '2026-01-03T00:00:00.000Z', + currentBookmark: { ProgressPercent: 20 }, + statistics: { Value: 1 }, + statusInfo: { Status: 'ReadyToRead' }, + }); + const service = new KoboReadingStateService(db as never, bookAccessService as never); + + await expect(service.getRawState(1, 44)).resolves.toBeNull(); + await expect(service.getRawState(1, 44)).resolves.toEqual({ + EntitlementId: '44', + Created: '2026-01-01T00:00:00.000Z', + LastModified: '2026-01-02T00:00:00.000Z', + PriorityTimestamp: '2026-01-03T00:00:00.000Z', + CurrentBookmark: { ProgressPercent: 20 }, + Statistics: { Value: 1 }, + StatusInfo: { Status: 'ReadyToRead' }, + }); + }); + + it('builds outgoing changed-reading-state payloads for progress push pages', async () => { + const db = makeDb(); + const rows = Array.from({ length: 251 }, (_, i) => ({ bookId: i + 1, percentage: i % 3 === 0 ? 95 : 40 })); + const selectChain = makeSelectChain(rows); + db.select.mockReturnValue(selectChain); + const stateInsert = makeInsertChain(); + db.insert.mockReturnValue(stateInsert); + const service = new KoboReadingStateService(db as never, bookAccessService as never); + + const result = await service.getAndMarkStatesNeedingPush(4, 30, 90); + + expect(result.hasMore).toBe(true); + expect(result.items).toHaveLength(250); + expect(result.items[0]).toEqual( + expect.objectContaining({ + ChangedReadingState: expect.objectContaining({ + ReadingState: expect.objectContaining({ + EntitlementId: '1', + CurrentBookmark: expect.objectContaining({ ProgressPercent: 95 }), + }), + }), + }), + ); + expect(stateInsert.values).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ userId: 4, bookId: 1 })])); + }); + + it('returns empty push payload when no updated reading progress rows exist', async () => { + const db = makeDb(); + db.select.mockReturnValue(makeSelectChain([])); + const service = new KoboReadingStateService(db as never, bookAccessService as never); + + await expect(service.getAndMarkStatesNeedingPush(4, 30, 90)).resolves.toEqual({ items: [], hasMore: false }); + expect(db.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/kobo/services/kobo-settings.service.test.ts b/server/src/modules/kobo/services/kobo-settings.service.test.ts new file mode 100644 index 000000000..e6dbac0f1 --- /dev/null +++ b/server/src/modules/kobo/services/kobo-settings.service.test.ts @@ -0,0 +1,158 @@ +import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; + +import { KoboSettingsService } from './kobo-settings.service'; + +function makeRow(overrides: Partial> = {}) { + return { + userId: 9, + readingThreshold: 2, + finishedThreshold: 90, + convertToKepub: false, + twoWayProgressSync: true, + forceEnableHyphenation: false, + kepubConversionLimitMb: 100, + ...overrides, + }; +} + +function makeDb() { + const findFirst = vi.fn(); + + const insertReturning = vi.fn(); + const insertOnConflictDoNothing = vi.fn().mockReturnValue({ returning: insertReturning }); + const insertValues = vi.fn().mockReturnValue({ onConflictDoNothing: insertOnConflictDoNothing }); + const insert = vi.fn().mockReturnValue({ values: insertValues }); + + const updateReturning = vi.fn(); + const updateWhere = vi.fn().mockReturnValue({ returning: updateReturning }); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const update = vi.fn().mockReturnValue({ set: updateSet }); + + return { + query: { + koboSyncSettings: { + findFirst, + }, + }, + insert, + insertValues, + insertOnConflictDoNothing, + insertReturning, + update, + updateSet, + updateWhere, + updateReturning, + }; +} + +describe('KoboSettingsService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns existing settings row when present', async () => { + const db = makeDb(); + db.query.koboSyncSettings.findFirst.mockResolvedValue(makeRow({ readingThreshold: 1.5 })); + const service = new KoboSettingsService(db as never); + + await expect(service.getSettings(9)).resolves.toEqual({ + readingThreshold: 1.5, + finishedThreshold: 90, + convertToKepub: false, + twoWayProgressSync: true, + forceEnableHyphenation: false, + kepubConversionLimitMb: 100, + }); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('creates missing settings row and falls back to re-read when insert returns nothing', async () => { + const db = makeDb(); + db.query.koboSyncSettings.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(makeRow({ readingThreshold: 3 })); + db.insertReturning.mockResolvedValueOnce([]); + const service = new KoboSettingsService(db as never); + + await expect(service.getSettings(7)).resolves.toEqual({ + readingThreshold: 3, + finishedThreshold: 90, + convertToKepub: false, + twoWayProgressSync: true, + forceEnableHyphenation: false, + kepubConversionLimitMb: 100, + }); + expect(db.insertValues).toHaveBeenCalledWith({ userId: 7 }); + }); + + it('throws when settings row cannot be created', async () => { + const db = makeDb(); + db.query.koboSyncSettings.findFirst.mockResolvedValue(null); + db.insertReturning.mockResolvedValue([]); + const service = new KoboSettingsService(db as never); + + await expect(service.getSettings(7)).rejects.toThrow(InternalServerErrorException); + }); + + it('rejects threshold updates where reading threshold is not below finished threshold', async () => { + const db = makeDb(); + const service = new KoboSettingsService(db as never); + vi.spyOn(service, 'getSettings').mockResolvedValue({ + readingThreshold: 2, + finishedThreshold: 90, + convertToKepub: false, + twoWayProgressSync: true, + forceEnableHyphenation: false, + kepubConversionLimitMb: 100, + }); + + await expect(service.updateSettings(9, { readingThreshold: 95 })).rejects.toThrow(BadRequestException); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('updates and returns merged settings fields', async () => { + const db = makeDb(); + const service = new KoboSettingsService(db as never); + vi.spyOn(service, 'getSettings').mockResolvedValue({ + readingThreshold: 2, + finishedThreshold: 90, + convertToKepub: false, + twoWayProgressSync: false, + forceEnableHyphenation: false, + kepubConversionLimitMb: 100, + }); + db.updateReturning.mockResolvedValue([ + makeRow({ + readingThreshold: 3, + finishedThreshold: 95, + convertToKepub: true, + twoWayProgressSync: true, + forceEnableHyphenation: true, + kepubConversionLimitMb: 200, + }), + ]); + + await expect(service.updateSettings(9, { readingThreshold: 3, finishedThreshold: 95, convertToKepub: true })).resolves.toEqual({ + readingThreshold: 3, + finishedThreshold: 95, + convertToKepub: true, + twoWayProgressSync: true, + forceEnableHyphenation: true, + kepubConversionLimitMb: 200, + }); + }); + + it('throws when update does not return a row', async () => { + const db = makeDb(); + const service = new KoboSettingsService(db as never); + vi.spyOn(service, 'getSettings').mockResolvedValue({ + readingThreshold: 2, + finishedThreshold: 90, + convertToKepub: false, + twoWayProgressSync: false, + forceEnableHyphenation: false, + kepubConversionLimitMb: 100, + }); + db.updateReturning.mockResolvedValue([]); + + await expect(service.updateSettings(9, { finishedThreshold: 91 })).rejects.toThrow(InternalServerErrorException); + }); +}); diff --git a/server/src/modules/kobo/services/kobo-settings.service.ts b/server/src/modules/kobo/services/kobo-settings.service.ts index 25d4bf5d7..7ab97fbfc 100644 --- a/server/src/modules/kobo/services/kobo-settings.service.ts +++ b/server/src/modules/kobo/services/kobo-settings.service.ts @@ -65,6 +65,10 @@ export class KoboSettingsService { .where(eq(schema.koboSyncSettings.userId, userId)) .returning(); + if (!updated) { + throw new InternalServerErrorException('Failed to update kobo settings row'); + } + return { readingThreshold: updated.readingThreshold, finishedThreshold: updated.finishedThreshold, diff --git a/server/src/modules/kobo/services/kobo-sync.service.test.ts b/server/src/modules/kobo/services/kobo-sync.service.test.ts new file mode 100644 index 000000000..e72809564 --- /dev/null +++ b/server/src/modules/kobo/services/kobo-sync.service.test.ts @@ -0,0 +1,426 @@ +import { KoboSyncService } from './kobo-sync.service'; + +type QueueState = { + select: unknown[]; + insert: unknown[]; + update: unknown[]; + delete: unknown[]; + execute: unknown[]; +}; + +function makeChain(result: unknown) { + const chain: Record = { + from: vi.fn(), + where: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + innerJoin: vi.fn(), + leftJoin: vi.fn(), + groupBy: vi.fn(), + offset: vi.fn(), + values: vi.fn(), + returning: vi.fn(), + onConflictDoNothing: vi.fn(), + onConflictDoUpdate: vi.fn(), + set: vi.fn(), + as: vi.fn(), + then: (onFulfilled: (value: unknown) => unknown, onRejected?: (error: unknown) => unknown) => + Promise.resolve(result).then(onFulfilled, onRejected), + catch: (onRejected: (error: unknown) => unknown) => Promise.resolve(result).catch(onRejected), + }; + + for (const key of [ + 'from', + 'where', + 'orderBy', + 'limit', + 'innerJoin', + 'leftJoin', + 'groupBy', + 'offset', + 'values', + 'returning', + 'onConflictDoNothing', + 'onConflictDoUpdate', + 'set', + 'as', + ]) { + (chain[key] as ReturnType).mockReturnValue(chain); + } + + return chain; +} + +function makeDb(state?: Partial) { + const queue: QueueState = { + select: [...(state?.select ?? [])], + insert: [...(state?.insert ?? [])], + update: [...(state?.update ?? [])], + delete: [...(state?.delete ?? [])], + execute: [...(state?.execute ?? [])], + }; + + return { + query: { + koboLibrarySnapshots: { findFirst: vi.fn() }, + koboSnapshotBooks: { findFirst: vi.fn() }, + collections: { findMany: vi.fn() }, + }, + select: vi.fn(() => makeChain(queue.select.shift() ?? [])), + insert: vi.fn(() => makeChain(queue.insert.shift() ?? [])), + update: vi.fn(() => makeChain(queue.update.shift() ?? [])), + delete: vi.fn(() => makeChain(queue.delete.shift() ?? [])), + execute: vi.fn(() => Promise.resolve({ rows: queue.execute.shift() ?? [] })), + transaction: vi.fn(async (cb: (tx: { execute: (statement: unknown) => Promise }) => Promise) => { + await cb({ + execute: vi.fn().mockResolvedValue(undefined), + }); + }), + }; +} + +function makeBook(id: number, format = 'epub') { + return { + bookId: id, + title: `Book ${id}`, + authors: ['Author One'], + description: 'Description', + publisher: 'Publisher', + publishedYear: 2022, + language: 'en', + seriesName: 'Series', + seriesIndex: 2, + fileFormat: format, + fileSizeBytes: 1234, + fileHash: `hash-${id}`, + metadataHash: `meta-${id}`, + metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + collectionNames: ['Sci-Fi'], + addedAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }; +} + +describe('KoboSyncService', () => { + const bookAccessService = { + getAccessibleLibraryIds: vi.fn(), + }; + const readingStateService = { + getAndMarkStatesNeedingPush: vi.fn(), + getRawState: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + bookAccessService.getAccessibleLibraryIds.mockResolvedValue(null); + readingStateService.getAndMarkStatesNeedingPush.mockResolvedValue({ items: [], hasMore: false }); + readingStateService.getRawState.mockResolvedValue(null); + }); + + it('getDelta creates new snapshot when missing and reconciles existing snapshot otherwise', async () => { + const db = makeDb(); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + const eligible = [{ bookId: 1, fileHash: 'h1', metadataHash: 'm1' }]; + vi.spyOn(service as any, 'fetchEligibleSnapshotRows').mockResolvedValue(eligible); + const createSpy = vi.spyOn(service as any, 'createSnapshot').mockResolvedValue(undefined); + const reconcileSpy = vi.spyOn(service as any, 'reconcileSnapshot').mockResolvedValue(undefined); + const pageSpy = vi.spyOn(service as any, 'getPageFromSnapshot').mockResolvedValue({ + entitlements: [{ ChangedTag: {} }], + hasMore: false, + syncToken: 'PX.token', + }); + + db.query.koboLibrarySnapshots.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 9, userId: 5 }); + + await expect( + service.getDelta(5, 'device-token', 'https://reader.example.com', null, { + readingThreshold: 2, + finishedThreshold: 90, + twoWayProgressSync: false, + }), + ).resolves.toEqual({ + entitlements: [{ ChangedTag: {} }], + hasMore: false, + syncToken: 'PX.token', + }); + expect(createSpy).toHaveBeenCalledWith(5, eligible); + + await service.getDelta(5, 'device-token', 'https://reader.example.com', null, { + readingThreshold: 2, + finishedThreshold: 90, + twoWayProgressSync: false, + }); + expect(reconcileSpy).toHaveBeenCalledWith(9, eligible); + expect(pageSpy).toHaveBeenCalledTimes(2); + }); + + it('getBookMetadata returns empty array when book is not eligible', async () => { + const service = new KoboSyncService(makeDb() as never, bookAccessService as never, readingStateService as never); + vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map()); + + await expect(service.getBookMetadata(3, 99, 'tok', 'https://base')).resolves.toEqual([]); + }); + + it('getBookMetadata returns mapped metadata payload for eligible book', async () => { + const service = new KoboSyncService(makeDb() as never, bookAccessService as never, readingStateService as never); + vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue(new Map([[12, makeBook(12, 'pdf')]])); + + const [metadata] = (await service.getBookMetadata(3, 12, 'tok', 'https://base')) as Array>; + + expect(metadata.Title).toBe('Book 12'); + expect(metadata.DownloadUrls).toEqual([ + { + Format: 'PDF', + Size: 1234, + Url: 'https://base/api/v1/kobo/tok/v1/books/12/download', + Platform: 'Generic', + DrmType: 'None', + }, + ]); + }); + + it('removeBookFromSync handles missing snapshot/row and delete-vs-mark paths', async () => { + const db = makeDb(); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + + db.query.koboLibrarySnapshots.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 4, userId: 1 }) + .mockResolvedValueOnce({ id: 4, userId: 1 }) + .mockResolvedValueOnce({ id: 4, userId: 1 }); + db.query.koboSnapshotBooks.findFirst + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ bookId: 2, pendingDelete: true }) + .mockResolvedValueOnce({ bookId: 3, pendingDelete: false }); + + await expect(service.removeBookFromSync(1, 10)).resolves.toBeUndefined(); + await expect(service.removeBookFromSync(1, 2)).resolves.toBeUndefined(); + await expect(service.removeBookFromSync(1, 3)).resolves.toBeUndefined(); + await expect(service.removeBookFromSync(1, 4)).resolves.toBeUndefined(); + + expect(db.delete).toHaveBeenCalled(); + expect(db.update).toHaveBeenCalled(); + }); + + it('invalidateSnapshot marks snapshot rows unsynced only when snapshot exists', async () => { + const db = makeDb(); + db.query.koboLibrarySnapshots.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 21, userId: 9 }); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + + await service.invalidateSnapshot(9); + await service.invalidateSnapshot(9); + + expect(db.update).toHaveBeenCalledTimes(1); + }); + + it('createSnapshot inserts snapshot row and snapshot-books seed rows', async () => { + const db = makeDb({ insert: [[{ id: 55 }], []] }); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + + await (service as any).createSnapshot(7, [ + { bookId: 1, fileHash: 'h1', metadataHash: 'm1' }, + { bookId: 2, fileHash: null, metadataHash: 'm2' }, + ]); + + expect(db.insert).toHaveBeenCalledTimes(2); + }); + + it('getPageFromSnapshot returns empty result when no snapshot exists', async () => { + const db = makeDb(); + db.query.koboLibrarySnapshots.findFirst.mockResolvedValue(null); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + + await expect( + (service as any).getPageFromSnapshot( + 7, + 'tok', + 'https://base', + { readingThreshold: 2, finishedThreshold: 90, twoWayProgressSync: false }, + new Set(), + ), + ).resolves.toEqual({ + entitlements: [], + hasMore: false, + syncToken: expect.stringMatching(/^PX\./), + }); + }); + + it('getPageFromSnapshot returns progress + tags on final page when no pending rows', async () => { + const db = makeDb({ select: [[]] }); + db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 1, userId: 7 }); + readingStateService.getAndMarkStatesNeedingPush.mockResolvedValue({ items: [{ ChangedReadingState: {} }], hasMore: false }); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + vi.spyOn(service as any, 'buildTagItems').mockResolvedValue([{ ChangedTag: {} }]); + + const result = await (service as any).getPageFromSnapshot( + 7, + 'tok', + 'https://base', + { readingThreshold: 2, finishedThreshold: 90, twoWayProgressSync: true }, + new Set([1]), + ); + + expect(result).toEqual({ + entitlements: [{ ChangedReadingState: {} }, { ChangedTag: {} }], + hasMore: false, + syncToken: expect.stringMatching(/^PX\./), + }); + }); + + it('getPageFromSnapshot returns page entitlements for removed/new/changed books', async () => { + const db = makeDb({ + select: [ + [ + { bookId: 1, pendingDelete: true, isNew: false }, + { bookId: 2, pendingDelete: false, isNew: true }, + { bookId: 3, pendingDelete: false, isNew: false }, + ], + ], + }); + db.query.koboLibrarySnapshots.findFirst.mockResolvedValue({ id: 22, userId: 7 }); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + vi.spyOn(service as any, 'fetchEligibleBooksByIds').mockResolvedValue( + new Map([ + [2, makeBook(2)], + [3, makeBook(3)], + ]), + ); + readingStateService.getRawState.mockResolvedValue(null); + + const result = await (service as any).getPageFromSnapshot( + 7, + 'tok', + 'https://base', + { readingThreshold: 2, finishedThreshold: 90, twoWayProgressSync: false }, + new Set([1, 2, 3]), + ); + + expect(result.hasMore).toBe(false); + expect(result.entitlements).toHaveLength(3); + expect(result.entitlements[0]).toHaveProperty('ChangedEntitlement'); + expect(result.entitlements[1]).toHaveProperty('NewEntitlement'); + expect(result.entitlements[2]).toHaveProperty('ChangedProductMetadata'); + expect(db.delete).toHaveBeenCalled(); + expect(db.update).toHaveBeenCalled(); + }); + + it('buildTagItems includes only currently-eligible books per collection', async () => { + const db = makeDb({ + select: [ + [ + { collectionId: 1, bookId: 10 }, + { collectionId: 1, bookId: 11 }, + { collectionId: 2, bookId: 22 }, + ], + ], + }); + db.query.collections.findMany.mockResolvedValue([ + { id: 1, name: 'Favorites' }, + { id: 2, name: 'Comics' }, + ]); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + + const tags = await (service as any).buildTagItems(3, new Set([10, 22])); + + expect(tags).toHaveLength(2); + expect(tags[0]).toEqual( + expect.objectContaining({ + ChangedTag: expect.objectContaining({ + Tag: expect.objectContaining({ + Id: 'col-1', + Items: [{ RevisionId: '10', Type: 'ProductRevisionTagItem' }], + }), + }), + }), + ); + }); + + it('buildMetadataHash is deterministic and changes when metadata inputs change', () => { + const service = new KoboSyncService(makeDb() as never, bookAccessService as never, readingStateService as never); + + const hashA = (service as any).buildMetadataHash({ + title: 'Dune', + authors: ['Frank Herbert'], + seriesName: 'Dune', + seriesIndex: 1, + metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + const hashB = (service as any).buildMetadataHash({ + title: 'Dune', + authors: ['Frank Herbert'], + seriesName: 'Dune', + seriesIndex: 1, + metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + const hashC = (service as any).buildMetadataHash({ + title: 'Dune Messiah', + authors: ['Frank Herbert'], + seriesName: 'Dune', + seriesIndex: 2, + metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + expect(hashA).toBe(hashB); + expect(hashA).not.toBe(hashC); + expect(hashA).toHaveLength(16); + }); + + it('fetchEligibleSnapshotRows and fetchEligibleBooksByIds map DB rows into sync payload objects', async () => { + const db = makeDb({ + select: [ + [ + { + bookId: 5, + title: 'Dune', + seriesName: 'Saga', + seriesIndex: 2, + metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + fileHash: 'file-hash', + authorNamesCsv: 'Author A,Author B', + }, + ], + [ + { + bookId: 5, + title: 'Dune', + description: 'Desc', + publisher: 'Pub', + publishedYear: 1965, + language: 'en', + seriesName: 'Saga', + seriesIndex: 2, + fileFormat: 'epub', + fileSizeBytes: 1234, + fileHash: 'file-hash', + metadataUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + addedAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ], + [{ bookId: 5, name: 'Author A' }], + [{ bookId: 5, name: 'Collection A' }], + ], + }); + const service = new KoboSyncService(db as never, bookAccessService as never, readingStateService as never); + vi.spyOn(service as any, 'buildEligibleBooksWhereClause').mockResolvedValue({ where: true }); + + const snapshotRows = await (service as any).fetchEligibleSnapshotRows(8); + expect(snapshotRows).toEqual([ + { + bookId: 5, + fileHash: 'file-hash', + metadataHash: expect.any(String), + }, + ]); + + const books = await (service as any).fetchEligibleBooksByIds(8, [5, 5]); + expect(books.get(5)).toEqual( + expect.objectContaining({ + title: 'Dune', + authors: ['Author A'], + collectionNames: ['Collection A'], + metadataHash: expect.any(String), + }), + ); + }); +}); diff --git a/server/src/modules/kobo/services/kobo-thumbnail.service.test.ts b/server/src/modules/kobo/services/kobo-thumbnail.service.test.ts new file mode 100644 index 000000000..6b48a9346 --- /dev/null +++ b/server/src/modules/kobo/services/kobo-thumbnail.service.test.ts @@ -0,0 +1,111 @@ +vi.mock('fs/promises', () => ({ + readdir: vi.fn(), + stat: vi.fn(), +})); + +vi.mock('fs', () => ({ + createReadStream: vi.fn(), +})); + +import { NotFoundException } from '@nestjs/common'; +import { createReadStream } from 'fs'; +import { readdir, stat } from 'fs/promises'; + +import { KoboThumbnailService } from './kobo-thumbnail.service'; + +const statMock = vi.mocked(stat); +const readdirMock = vi.mocked(readdir); +const createReadStreamMock = vi.mocked(createReadStream); + +function makeReply() { + return { + status: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + }; +} + +describe('KoboThumbnailService', () => { + const bookAccessService = { + assertBookAccessible: vi.fn(), + }; + const config = { + get: vi.fn().mockReturnValue('/app-data'), + }; + let service: KoboThumbnailService; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + service = new KoboThumbnailService(config as never, bookAccessService as never); + bookAccessService.assertBookAccessible.mockResolvedValue(undefined); + }); + + it('returns 304 when thumbnail etag matches if-none-match', async () => { + const reply = makeReply(); + statMock.mockResolvedValueOnce({ mtimeMs: 1234 } as never); + + await service.serveThumbnail(7, 12, '"1234"', reply as never); + + expect(bookAccessService.assertBookAccessible).toHaveBeenCalledWith(7, 12); + expect(reply.status).toHaveBeenCalledWith(304); + expect(reply.send).toHaveBeenCalledWith(); + expect(createReadStreamMock).not.toHaveBeenCalled(); + }); + + it('streams thumbnail bytes and sets cache headers', async () => { + const reply = makeReply(); + const stream = {} as never; + statMock.mockResolvedValueOnce({ mtimeMs: 5678 } as never); + createReadStreamMock.mockReturnValue(stream); + + await service.serveThumbnail(7, 12, undefined, reply as never); + + expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'max-age=86400'); + expect(reply.header).toHaveBeenCalledWith('ETag', '"5678"'); + expect(reply.type).toHaveBeenCalledWith('image/jpeg'); + expect(reply.send).toHaveBeenCalledWith(stream); + }); + + it('falls back to cover serving when thumbnail file lookup fails', async () => { + const reply = makeReply(); + statMock.mockRejectedValueOnce(new Error('missing thumbnail')); + const coverSpy = vi.spyOn(service, 'serveCover').mockResolvedValue(undefined); + + await service.serveThumbnail(7, 12, undefined, reply as never); + + expect(coverSpy).toHaveBeenCalledWith(12, undefined, reply); + }); + + it('serveCover uses preferred cover file and returns matching content type', async () => { + const reply = makeReply(); + const stream = {} as never; + readdirMock.mockResolvedValueOnce(['cover_extracted.jpg', 'cover_custom.png']); + statMock.mockResolvedValueOnce({ mtimeMs: 9999 } as never); + createReadStreamMock.mockReturnValue(stream); + + await service.serveCover(25, undefined, reply as never); + + expect(reply.header).toHaveBeenCalledWith('ETag', '"9999"'); + expect(reply.type).toHaveBeenCalledWith('image/png'); + expect(reply.send).toHaveBeenCalledWith(stream); + }); + + it('serveCover returns 304 for matching cover etag', async () => { + const reply = makeReply(); + readdirMock.mockResolvedValueOnce(['cover_extracted.jpg']); + statMock.mockResolvedValueOnce({ mtimeMs: 2222 } as never); + + await service.serveCover(13, '"2222"', reply as never); + + expect(reply.status).toHaveBeenCalledWith(304); + expect(reply.send).toHaveBeenCalledWith(); + }); + + it('throws NotFoundException when no cover image is available', async () => { + readdirMock.mockResolvedValueOnce([]); + + await expect(service.serveCover(25, undefined, makeReply() as never)).rejects.toThrow(NotFoundException); + }); +}); diff --git a/server/src/modules/lens/lens.controller.test.ts b/server/src/modules/lens/lens.controller.test.ts new file mode 100644 index 000000000..a2f7fe953 --- /dev/null +++ b/server/src/modules/lens/lens.controller.test.ts @@ -0,0 +1,52 @@ +import type { RequestUser } from '../../common/types/request-user'; +import { LensController } from './lens.controller'; + +function makeUser(overrides: Partial = {}): RequestUser { + return { + id: 12, + username: 'reader', + name: 'Reader', + email: null, + active: true, + isSuperuser: false, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + ...overrides, + }; +} + +describe('LensController', () => { + it('forwards each route handler to LensService with parsed args', async () => { + const lensService = { + findAll: vi.fn().mockResolvedValue([]), + findOne: vi.fn().mockResolvedValue({ id: 1 }), + create: vi.fn().mockResolvedValue({ id: 2 }), + reorder: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue({ id: 1, name: 'Updated' }), + remove: vi.fn().mockResolvedValue(undefined), + executeLens: vi.fn().mockResolvedValue({ items: [], total: 0, page: 0, size: 50 }), + }; + const controller = new LensController(lensService as never); + const user = makeUser(); + + await controller.findAll(user); + await controller.findOne(1, user); + await controller.create({ name: 'New lens' } as never, user); + await controller.reorder({ order: [{ id: 1, displayOrder: 0 }] } as never, user); + await controller.update(1, { name: 'Updated' } as never, user); + await controller.remove(1, user); + await controller.executeLens(1, user, 2, 25); + + expect(lensService.findAll).toHaveBeenCalledWith(user); + expect(lensService.findOne).toHaveBeenCalledWith(1, user); + expect(lensService.create).toHaveBeenCalledWith({ name: 'New lens' }, user); + expect(lensService.reorder).toHaveBeenCalledWith({ order: [{ id: 1, displayOrder: 0 }] }, user); + expect(lensService.update).toHaveBeenCalledWith(1, { name: 'Updated' }, user); + expect(lensService.remove).toHaveBeenCalledWith(1, user); + expect(lensService.executeLens).toHaveBeenCalledWith(1, user, 2, 25); + }); +}); diff --git a/server/src/modules/lens/lens.repository.test.ts b/server/src/modules/lens/lens.repository.test.ts new file mode 100644 index 000000000..5d786531b --- /dev/null +++ b/server/src/modules/lens/lens.repository.test.ts @@ -0,0 +1,61 @@ +import { lenses } from '../../db/schema'; +import { LensRepository } from './lens.repository'; + +describe('LensRepository', () => { + it('builds find/update/delete queries and counts display-order updates inside a transaction', async () => { + const findAllOrderBy = vi.fn().mockReturnValue('find-all-query'); + const findByIdLimit = vi.fn().mockResolvedValue([{ id: 1 }]); + const where = vi.fn().mockReturnValue({ orderBy: findAllOrderBy, limit: findByIdLimit }); + const from = vi.fn().mockReturnValue({ where }); + + const insertReturning = vi.fn().mockResolvedValue([{ id: 2 }]); + const insertValues = vi.fn().mockReturnValue({ returning: insertReturning }); + const insert = vi.fn().mockReturnValue({ values: insertValues }); + + const updateReturning = vi.fn().mockResolvedValue([{ id: 3 }]); + const updateWhere = vi.fn().mockReturnValue({ returning: updateReturning }); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const update = vi.fn().mockReturnValue({ set: updateSet }); + + const deleteReturning = vi.fn().mockResolvedValue([{ id: 4 }]); + const deleteWhere = vi.fn().mockReturnValue({ returning: deleteReturning }); + const deleteFn = vi.fn().mockReturnValue({ where: deleteWhere }); + + const txReturning = vi + .fn() + .mockResolvedValueOnce([{ id: 10 }]) + .mockResolvedValueOnce([]); + const txWhere = vi.fn().mockReturnValue({ returning: txReturning }); + const txSet = vi.fn().mockReturnValue({ where: txWhere }); + const txUpdate = vi.fn().mockReturnValue({ set: txSet }); + const transaction = vi + .fn() + .mockImplementation(async (callback: (tx: { update: typeof txUpdate }) => Promise) => callback({ update: txUpdate })); + + const db = { + select: vi.fn().mockReturnValue({ from }), + insert, + update, + delete: deleteFn, + transaction, + }; + + const repo = new LensRepository(db as never); + + expect(repo.findAllForUser(9)).toBe('find-all-query'); + expect(findAllOrderBy).toHaveBeenCalledWith(lenses.displayOrder, lenses.name); + + await expect(repo.findById(1)).resolves.toEqual([{ id: 1 }]); + await expect(repo.insert({ userId: 9, name: 'Favorites' } as never)).resolves.toEqual([{ id: 2 }]); + await expect(repo.update(3, 9, { name: 'Renamed' })).resolves.toEqual([{ id: 3 }]); + await expect(repo.delete(4, 9)).resolves.toEqual([{ id: 4 }]); + + const updatedCount = await repo.updateDisplayOrders(9, [ + { id: 100, displayOrder: 0 }, + { id: 101, displayOrder: 1 }, + ]); + expect(updatedCount).toBe(1); + expect(transaction).toHaveBeenCalledTimes(1); + expect(txUpdate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/server/src/modules/metadata-fetch/fetch-with-throttle.test.ts b/server/src/modules/metadata-fetch/fetch-with-throttle.test.ts new file mode 100644 index 000000000..bf1b26dfe --- /dev/null +++ b/server/src/modules/metadata-fetch/fetch-with-throttle.test.ts @@ -0,0 +1,67 @@ +import { fetchWithThrottle } from './fetch-with-throttle'; +import { ProviderThrottleError } from './provider-throttle.error'; + +describe('fetchWithThrottle', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns the response when provider is not throttling', async () => { + const response = { + status: 200, + headers: { get: vi.fn() }, + } as unknown as Response; + global.fetch = vi.fn().mockResolvedValue(response); + + await expect(fetchWithThrottle('https://provider.example/search')).resolves.toBe(response); + }); + + it('throws ProviderThrottleError with numeric Retry-After seconds', async () => { + global.fetch = vi.fn().mockResolvedValue({ + status: 429, + headers: { get: vi.fn().mockReturnValue('120') }, + }); + + await expect(fetchWithThrottle('https://provider.example/search')).rejects.toMatchObject({ + name: 'ProviderThrottleError', + retryAfterSeconds: 120, + }); + }); + + it('parses Retry-After HTTP-date headers into remaining seconds', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T12:00:00.000Z')); + + const retryAt = new Date('2026-04-08T12:02:05.000Z').toUTCString(); + global.fetch = vi.fn().mockResolvedValue({ + status: 429, + headers: { get: vi.fn().mockReturnValue(retryAt) }, + }); + + await expect(fetchWithThrottle('https://provider.example/search')).rejects.toMatchObject({ + retryAfterSeconds: 125, + }); + }); + + it('falls back to undefined retryAfterSeconds for invalid, zero, or past headers', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-08T12:00:00.000Z')); + + const retryAfterHeaders = ['0', '-5', 'not-a-date', new Date('2026-04-08T11:59:00.000Z').toUTCString()]; + + for (const header of retryAfterHeaders) { + global.fetch = vi.fn().mockResolvedValue({ + status: 429, + headers: { get: vi.fn().mockReturnValue(header) }, + }); + + try { + await fetchWithThrottle('https://provider.example/search'); + } catch (error) { + expect(error).toBeInstanceOf(ProviderThrottleError); + expect((error as ProviderThrottleError).retryAfterSeconds).toBeUndefined(); + } + } + }); +}); diff --git a/server/src/modules/metadata-fetch/fetch-with-throttle.ts b/server/src/modules/metadata-fetch/fetch-with-throttle.ts index aa71ef939..3551e2f1b 100644 --- a/server/src/modules/metadata-fetch/fetch-with-throttle.ts +++ b/server/src/modules/metadata-fetch/fetch-with-throttle.ts @@ -1,11 +1,26 @@ import { ProviderThrottleError } from './provider-throttle.error'; +function parseRetryAfterSeconds(retryAfter: string | null): number | undefined { + if (!retryAfter) return undefined; + + const numeric = Number(retryAfter); + if (Number.isFinite(numeric) && numeric > 0) { + return Math.ceil(numeric); + } + + const retryAtEpochMs = Date.parse(retryAfter); + if (Number.isNaN(retryAtEpochMs)) { + return undefined; + } + + const remainingSeconds = Math.ceil((retryAtEpochMs - Date.now()) / 1000); + return remainingSeconds > 0 ? remainingSeconds : undefined; +} + export async function fetchWithThrottle(url: string | URL, options?: RequestInit): Promise { const res = await fetch(url, options); if (res.status === 429) { - const retryAfter = res.headers.get('Retry-After'); - const parsedRetryAfterSeconds = retryAfter ? Number(retryAfter) : Number.NaN; - const retryAfterSeconds = Number.isFinite(parsedRetryAfterSeconds) && parsedRetryAfterSeconds > 0 ? parsedRetryAfterSeconds : undefined; + const retryAfterSeconds = parseRetryAfterSeconds(res.headers.get('Retry-After')); throw new ProviderThrottleError(retryAfterSeconds); } return res; diff --git a/server/src/modules/metadata-fetch/metadata-fetch.repository.test.ts b/server/src/modules/metadata-fetch/metadata-fetch.repository.test.ts new file mode 100644 index 000000000..3d4a732ce --- /dev/null +++ b/server/src/modules/metadata-fetch/metadata-fetch.repository.test.ts @@ -0,0 +1,74 @@ +import { MetadataFetchRepository, type StoredProviderIdsRow } from './metadata-fetch.repository'; + +function makeRepository() { + const limit = vi.fn(); + const where = vi.fn().mockReturnValue({ limit }); + const leftJoin = vi.fn().mockReturnValue({ where }); + const from = vi.fn().mockReturnValue({ leftJoin }); + const select = vi.fn().mockReturnValue({ from }); + const findFirst = vi.fn(); + + const db = { + select, + query: { + userLibraryAccess: { + findFirst, + }, + }, + }; + + return { + repo: new MetadataFetchRepository(db as never), + limit, + where, + leftJoin, + from, + select, + findFirst, + }; +} + +describe('MetadataFetchRepository', () => { + it('returns provider-id row for a known book', async () => { + const { repo, limit } = makeRepository(); + const row: StoredProviderIdsRow = { + libraryId: 5, + googleBooksId: 'google-1', + goodreadsId: 'goodreads-1', + amazonId: 'amazon-1', + hardcoverId: 'hardcover-1', + openLibraryId: 'open-library-1', + itunesId: 'itunes-1', + audibleId: 'audible-1', + comicvineId: 'comicvine-1', + }; + limit.mockResolvedValue([row]); + + await expect(repo.findStoredProviderIdsRow(99)).resolves.toEqual(row); + }); + + it('returns null when provider-id row is missing for a book', async () => { + const { repo, limit } = makeRepository(); + limit.mockResolvedValue([]); + + await expect(repo.findStoredProviderIdsRow(100)).resolves.toBeNull(); + }); + + it('returns true when user has explicit library access', async () => { + const { repo, findFirst } = makeRepository(); + findFirst.mockResolvedValue({ userId: 7 }); + + await expect(repo.hasLibraryAccess(7, 11)).resolves.toBe(true); + expect(findFirst).toHaveBeenCalledWith({ + where: expect.anything(), + columns: { userId: true }, + }); + }); + + it('returns false when no access row exists', async () => { + const { repo, findFirst } = makeRepository(); + findFirst.mockResolvedValue(null); + + await expect(repo.hasLibraryAccess(7, 11)).resolves.toBe(false); + }); +}); diff --git a/server/src/modules/metadata-fetch/provider-throttle.error.test.ts b/server/src/modules/metadata-fetch/provider-throttle.error.test.ts new file mode 100644 index 000000000..18787eaa9 --- /dev/null +++ b/server/src/modules/metadata-fetch/provider-throttle.error.test.ts @@ -0,0 +1,17 @@ +import { ProviderThrottleError } from './provider-throttle.error'; + +describe('ProviderThrottleError', () => { + it('preserves message, name, and retry-after seconds', () => { + const error = new ProviderThrottleError(45); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('ProviderThrottleError'); + expect(error.message).toBe('Provider throttled (HTTP 429)'); + expect(error.retryAfterSeconds).toBe(45); + }); + + it('supports undefined retry-after seconds', () => { + const error = new ProviderThrottleError(); + expect(error.retryAfterSeconds).toBeUndefined(); + }); +}); diff --git a/server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts b/server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts new file mode 100644 index 000000000..c55d3ace2 --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/audible/audible.provider.test.ts @@ -0,0 +1,206 @@ +import type { ProviderConfigurations } from '@projectx/types'; + +import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service'; +import { fetchWithThrottle } from '../../fetch-with-throttle'; +import { ProviderThrottleError } from '../../provider-throttle.error'; +import { buildRequestSignal } from '../provider-utils'; +import { mapAudibleProduct } from './audible.mapper'; +import { AudibleProvider } from './audible.provider'; + +vi.mock('../../fetch-with-throttle', () => ({ + fetchWithThrottle: vi.fn(), +})); + +vi.mock('../provider-utils', () => ({ + buildRequestSignal: vi.fn(() => new AbortController().signal), +})); + +vi.mock('./audible.mapper', () => ({ + mapAudibleProduct: vi.fn((product: { asin?: string; title?: string }) => ({ + provider: 'audible', + providerId: product.asin ?? 'missing', + title: product.title ?? 'untitled', + })), +})); + +const mockFetchWithThrottle = fetchWithThrottle as MockedFunction; +const mockBuildRequestSignal = buildRequestSignal as MockedFunction; +const mockMapAudibleProduct = mapAudibleProduct as MockedFunction; + +const baseConfig: ProviderConfigurations = { + google: { enabled: false, apiKey: '' }, + amazon: { enabled: false, domain: 'amazon.com', cookie: '' }, + goodreads: { enabled: false }, + hardcover: { enabled: false, apiKey: '' }, + openLibrary: { enabled: false }, + itunes: { enabled: false, coverResolution: 'high' }, + audible: { enabled: true, domain: 'com' }, + audnexus: { enabled: false }, + comicvine: { enabled: false, apiKey: '' }, +}; + +function makeProvider(config: ProviderConfigurations = baseConfig): { + provider: AudibleProvider; + providerConfig: { getConfig: MockedFunction<() => Promise> }; +} { + const providerConfig = { + getConfig: vi.fn().mockResolvedValue(config), + }; + + return { + provider: new AudibleProvider(providerConfig as unknown as ProviderConfigService), + providerConfig, + }; +} + +describe('AudibleProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty results when provider is disabled', async () => { + const { provider } = makeProvider({ + ...baseConfig, + audible: { enabled: false, domain: 'com' }, + }); + + await expect(provider.search({ title: 'Dune', isAudiobook: true })).resolves.toEqual([]); + expect(mockFetchWithThrottle).not.toHaveBeenCalled(); + }); + + it('returns empty results for non-audiobook searches', async () => { + const { provider } = makeProvider(); + + await expect(provider.search({ title: 'Dune', isAudiobook: false })).resolves.toEqual([]); + expect(mockFetchWithThrottle).not.toHaveBeenCalled(); + }); + + it('returns empty results when there is no title/author query', async () => { + const { provider } = makeProvider(); + + await expect(provider.search({ isAudiobook: true })).resolves.toEqual([]); + expect(mockFetchWithThrottle).not.toHaveBeenCalled(); + }); + + it('searches audible with normalized domain and mapped response', async () => { + const { provider } = makeProvider({ + ...baseConfig, + audible: { enabled: true, domain: 'https://api.audible.co.uk/store' }, + }); + + mockFetchWithThrottle.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + products: [ + { asin: 'A1', title: 'Dune' }, + { asin: 'A2', title: 'Dune Messiah' }, + ], + }), + } as unknown as Response); + + const result = await provider.search({ title: 'Dune', author: 'Frank Herbert', isAudiobook: true }); + + expect(mockFetchWithThrottle).toHaveBeenCalledWith( + expect.stringContaining('https://api.audible.co.uk/1.0/catalog/products'), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + expect(mockFetchWithThrottle).toHaveBeenCalledWith(expect.stringContaining('keywords=Dune+Frank+Herbert'), expect.any(Object)); + expect(mockBuildRequestSignal).toHaveBeenCalled(); + expect(mockMapAudibleProduct).toHaveBeenCalledTimes(2); + expect(result).toEqual([ + { provider: 'audible', providerId: 'A1', title: 'Dune' }, + { provider: 'audible', providerId: 'A2', title: 'Dune Messiah' }, + ]); + }); + + it('returns empty results on non-ok search responses', async () => { + const { provider } = makeProvider(); + mockFetchWithThrottle.mockResolvedValue({ + ok: false, + status: 503, + json: vi.fn(), + } as unknown as Response); + + await expect(provider.search({ title: 'Dune', isAudiobook: true })).resolves.toEqual([]); + }); + + it('rethrows throttling errors from search', async () => { + const { provider } = makeProvider(); + mockFetchWithThrottle.mockRejectedValue(new ProviderThrottleError(90)); + + await expect(provider.search({ title: 'Dune', isAudiobook: true })).rejects.toBeInstanceOf(ProviderThrottleError); + }); + + it('returns empty results on unexpected search failures', async () => { + const { provider } = makeProvider(); + mockFetchWithThrottle.mockRejectedValue(new Error('network down')); + + await expect(provider.search({ title: 'Dune', isAudiobook: true })).resolves.toEqual([]); + }); + + it('returns null from lookupById when provider is disabled', async () => { + const { provider } = makeProvider({ + ...baseConfig, + audible: { enabled: false, domain: 'com' }, + }); + + await expect(provider.lookupById('A1')).resolves.toBeNull(); + expect(mockFetchWithThrottle).not.toHaveBeenCalled(); + }); + + it('looks up a product by provider id and maps the result', async () => { + const { provider } = makeProvider(); + mockFetchWithThrottle.mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + product: { asin: 'A1', title: 'Dune' }, + }), + } as unknown as Response); + + await expect(provider.lookupById('A1')).resolves.toEqual({ + provider: 'audible', + providerId: 'A1', + title: 'Dune', + }); + expect(mockFetchWithThrottle).toHaveBeenCalledWith( + expect.stringContaining('/1.0/catalog/products/A1'), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('returns null for non-ok lookup responses or empty product payloads', async () => { + const { provider } = makeProvider(); + mockFetchWithThrottle + .mockResolvedValueOnce({ + ok: false, + status: 404, + json: vi.fn(), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ product: null }), + } as unknown as Response); + + await expect(provider.lookupById('missing')).resolves.toBeNull(); + await expect(provider.lookupById('empty')).resolves.toBeNull(); + }); + + it('rethrows throttling errors from lookupById', async () => { + const { provider } = makeProvider(); + mockFetchWithThrottle.mockRejectedValue(new ProviderThrottleError(45)); + + await expect(provider.lookupById('A1')).rejects.toBeInstanceOf(ProviderThrottleError); + }); + + it('returns null on unexpected lookup failures', async () => { + const { provider } = makeProvider(); + mockFetchWithThrottle.mockRejectedValue(new Error('request failed')); + + await expect(provider.lookupById('A1')).resolves.toBeNull(); + }); +}); diff --git a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts index 6002d110e..30a906f8e 100644 --- a/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts +++ b/server/src/modules/metadata-fetch/providers/audnexus/audnexus.provider.test.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { MetadataProviderKey, ProviderConfigurations } from '@projectx/types'; import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service'; +import { ProviderThrottleError } from '../../provider-throttle.error'; import { AudnexusProvider } from './audnexus.provider'; function jsonResponse(body: unknown, status = 200): Response { @@ -117,4 +118,87 @@ describe('AudnexusProvider', () => { expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('https://api.audible.com/1.0/catalog/products'), expect.any(Object)); expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('keywords=Artificial+Condition+Martha+Wells'), expect.any(Object)); }); + + it('returns empty results when title/author cannot produce an Audible query', async () => { + global.fetch = vi.fn(); + + const result = await provider.search({ + title: ' ', + author: ' ', + isAudiobook: true, + }); + + expect(result).toEqual([]); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns empty results for non-ok Audible or AudNexus book responses', async () => { + global.fetch = vi + .fn() + .mockImplementationOnce(() => Promise.resolve(jsonResponse({ products: [] }, 503))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse({ products: [{ asin: 'B0BROKEN001' }] }))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse({}, 404))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse({}, 200))); + + const resolveFailure = await provider.search({ + title: 'Failure One', + isAudiobook: true, + }); + const lookupFailure = await provider.search({ + title: 'Failure Two', + isAudiobook: true, + }); + + expect(resolveFailure).toEqual([]); + expect(lookupFailure).toEqual([]); + }); + + it('returns metadata even when chapters lookup fails, and propagates throttle errors', async () => { + global.fetch = vi + .fn() + .mockImplementationOnce(() => Promise.resolve(jsonResponse({ products: [{ asin: 'B0CHAPTERS01' }] }))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse({ asin: 'B0CHAPTERS01', name: 'Book without chapters' }))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse({}, 500))) + .mockImplementationOnce(() => Promise.reject(new ProviderThrottleError(30))); + + const result = await provider.search({ + title: 'Book without chapters', + isAudiobook: true, + }); + expect(result).toHaveLength(1); + expect(result[0].providerId).toBe('B0CHAPTERS01'); + + await expect( + provider.search({ + title: 'Throttle me', + isAudiobook: true, + }), + ).rejects.toThrow(ProviderThrottleError); + }); + + it('returns empty results when Audible ASIN resolution throws a non-throttle error', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('audible unavailable')); + + const result = await provider.search({ + title: 'Transient Audible failure', + isAudiobook: true, + }); + + expect(result).toEqual([]); + }); + + it('returns empty results when internal AudNexus lookup throws an unexpected error', async () => { + const fetchByAsinSpy = vi.spyOn(provider as any, 'fetchByAsin').mockRejectedValue(new Error('unexpected lookup crash')); + + const result = await provider.search({ + title: 'Use stored ASIN', + isAudiobook: true, + existingProviderIds: { + [MetadataProviderKey.AUDIBLE]: 'B0INTERNAL01', + }, + }); + + expect(result).toEqual([]); + expect(fetchByAsinSpy).toHaveBeenCalledWith('B0INTERNAL01', undefined); + }); }); diff --git a/server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.test.ts b/server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.test.ts new file mode 100644 index 000000000..6ff33a28b --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.test.ts @@ -0,0 +1,116 @@ +import { MetadataProviderKey } from '@projectx/types'; + +import { mapIssueToCandidate } from './comicvine.mapper'; +import type { ComicVineIssue } from './comicvine.types'; + +function makeIssue(overrides: Partial = {}): ComicVineIssue { + return { + id: 100, + name: 'Issue Name', + issue_number: '1', + cover_date: '2024-05-01', + store_date: null, + description: 'Issue description', + deck: null, + image: { original_url: 'https://example.com/cover.jpg' }, + volume: { id: 10, name: 'Series Name' }, + site_detail_url: 'https://comicvine.gamespot.com/issue', + person_credits: [], + character_credits: [], + team_credits: [], + story_arc_credits: [], + location_credits: [], + ...overrides, + }; +} + +describe('mapIssueToCandidate', () => { + it('maps rich issue metadata including role-based contributor fields', () => { + const candidate = mapIssueToCandidate( + makeIssue({ + id: 777, + name: 'The Origin', + issue_number: '12.5', + description: null, + deck: 'Fallback synopsis', + person_credits: [ + { id: 1, name: 'Writer A', role: 'writer' }, + { id: 2, name: 'Artist A', role: 'penciller' }, + { id: 3, name: 'Artist B', role: 'artist, inker' }, + { id: 4, name: 'Colorist A', role: 'Colorist, Cover Artist' }, + { id: 5, name: 'Letterer A', role: 'letterer' }, + ], + character_credits: [{ id: 21, name: 'Character A' }], + team_credits: [{ id: 31, name: 'Team A' }], + location_credits: [{ id: 41, name: 'Location A' }], + story_arc_credits: [{ id: 51, name: 'Arc A' }], + }), + ); + + expect(candidate).toEqual( + expect.objectContaining({ + provider: MetadataProviderKey.COMICVINE, + providerId: '777', + title: 'Series Name #12.5 - The Origin', + subtitle: 'The Origin', + authors: ['Writer A'], + description: 'Fallback synopsis', + publishedYear: 2024, + seriesName: 'Series Name', + seriesIndex: 12.5, + coverUrl: 'https://example.com/cover.jpg', + sourceUrl: 'https://comicvine.gamespot.com/issue', + }), + ); + expect(candidate.comicMetadata).toEqual( + expect.objectContaining({ + issueNumber: '12.5', + volumeName: 'Series Name', + pencillers: ['Artist A', 'Artist B'], + inkers: ['Artist B'], + colorists: ['Colorist A'], + letterers: ['Letterer A'], + coverArtists: ['Colorist A'], + characters: ['Character A'], + teams: ['Team A'], + locations: ['Location A'], + storyArcs: ['Arc A'], + }), + ); + }); + + it('uses store_date fallback and treats malformed years as unknown', () => { + const fromStoreDate = mapIssueToCandidate( + makeIssue({ + cover_date: null, + store_date: '2019-11-03', + }), + ); + expect(fromStoreDate.publishedYear).toBe(2019); + + const malformedYear = mapIssueToCandidate( + makeIssue({ + name: null, + issue_number: '7', + cover_date: '20XX-04-03', + store_date: null, + }), + ); + expect(malformedYear.title).toBe('Series Name #7'); + expect(malformedYear.publishedYear).toBeUndefined(); + }); + + it('keeps optional fields undefined when source values are not usable', () => { + const candidate = mapIssueToCandidate( + makeIssue({ + issue_number: 'Annual Special', + image: null, + site_detail_url: null, + }), + ); + + expect(candidate.seriesIndex).toBeUndefined(); + expect(candidate.coverUrl).toBeUndefined(); + expect(candidate.sourceUrl).toBeUndefined(); + }); +}); diff --git a/server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.ts b/server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.ts index 091486f9a..3c1798fc7 100644 --- a/server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.ts +++ b/server/src/modules/metadata-fetch/providers/comicvine/comicvine.mapper.ts @@ -4,7 +4,9 @@ import { ComicVineIssue, ComicVinePersonCredit } from './comicvine.types'; function parseYear(dateStr: string | null | undefined): number | undefined { if (!dateStr) return undefined; - const year = parseInt(dateStr.substring(0, 4), 10); + const yearToken = dateStr.substring(0, 4); + if (!/^\d{4}$/.test(yearToken)) return undefined; + const year = parseInt(yearToken, 10); return Number.isNaN(year) ? undefined : year; } diff --git a/server/src/modules/metadata-fetch/providers/metadata-search-params.test.ts b/server/src/modules/metadata-fetch/providers/metadata-search-params.test.ts new file mode 100644 index 000000000..7fa5312f2 --- /dev/null +++ b/server/src/modules/metadata-fetch/providers/metadata-search-params.test.ts @@ -0,0 +1,26 @@ +import { MetadataProviderKey } from '@projectx/types'; + +import type { MetadataSearchParams } from './metadata-search-params'; + +describe('MetadataSearchParams', () => { + it('supports partial provider id hints and orchestration-only fields together', () => { + const signal = new AbortController().signal; + const params: MetadataSearchParams = { + title: 'Dune', + author: 'Frank Herbert', + isbn: '9780441013593', + isAudiobook: false, + maxCandidatesPerProvider: 5, + signal, + existingProviderIds: { + [MetadataProviderKey.GOOGLE]: 'google-123', + [MetadataProviderKey.OPEN_LIBRARY]: 'OL82563W', + }, + }; + + expect(params.existingProviderIds?.[MetadataProviderKey.GOOGLE]).toBe('google-123'); + expect(params.existingProviderIds?.[MetadataProviderKey.OPEN_LIBRARY]).toBe('OL82563W'); + expect(params.maxCandidatesPerProvider).toBe(5); + expect(params.signal).toBe(signal); + }); +}); diff --git a/server/src/modules/metadata/comic-metadata.repository.test.ts b/server/src/modules/metadata/comic-metadata.repository.test.ts new file mode 100644 index 000000000..62e1b5067 --- /dev/null +++ b/server/src/modules/metadata/comic-metadata.repository.test.ts @@ -0,0 +1,73 @@ +import * as schema from '../../db/schema'; +import { ComicMetadataRepository } from './comic-metadata.repository'; + +function makeRepository() { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + + const findFirst = vi.fn(); + const db = { + insert, + query: { + comicMetadata: { + findFirst, + }, + }, + }; + + return { + repo: new ComicMetadataRepository(db as never), + insert, + values, + onConflictDoUpdate, + findFirst, + }; +} + +describe('ComicMetadataRepository', () => { + it('upserts comic metadata fields and updates updatedAt on conflicts', async () => { + const { repo, insert, values, onConflictDoUpdate } = makeRepository(); + + await repo.upsert(17, { + issueNumber: '42', + volumeName: 'Saga', + pencillers: ['A'], + inkers: ['B'], + colorists: ['C'], + letterers: ['D'], + coverArtists: ['E'], + characters: ['F'], + teams: ['G'], + locations: ['H'], + storyArcs: ['I'], + }); + + expect(insert).toHaveBeenCalledWith(schema.comicMetadata); + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + bookId: 17, + issueNumber: '42', + volumeName: 'Saga', + }), + ); + expect(onConflictDoUpdate).toHaveBeenCalledWith({ + target: schema.comicMetadata.bookId, + set: expect.objectContaining({ + issueNumber: '42', + volumeName: 'Saga', + updatedAt: expect.any(Date), + }), + }); + }); + + it('returns null when no comic metadata row exists for a book', async () => { + const { repo, findFirst } = makeRepository(); + findFirst.mockResolvedValue(undefined); + + await expect(repo.findByBookId(999)).resolves.toBeNull(); + expect(findFirst).toHaveBeenCalledWith({ + where: expect.anything(), + }); + }); +}); diff --git a/server/src/modules/metadata/extractors/audio.extractor.test.ts b/server/src/modules/metadata/extractors/audio.extractor.test.ts index 0fbdf5817..b727671de 100644 --- a/server/src/modules/metadata/extractors/audio.extractor.test.ts +++ b/server/src/modules/metadata/extractors/audio.extractor.test.ts @@ -5,10 +5,26 @@ vi.mock('music-metadata', () => ({ parseFile: vi.fn(), })); +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: vi.fn(), + }; +}); + +vi.mock('mediainfo.js', () => ({ + default: vi.fn(), +})); + import * as mm from 'music-metadata'; +import { open } from 'fs/promises'; +import mediaInfoFactory from 'mediainfo.js'; import { extractAudioMetadata, parseAudioDuration } from './audio.extractor'; const mockParseFile = mm.parseFile as ReturnType; +const mockOpen = open as unknown as ReturnType; +const mockMediaInfoFactory = mediaInfoFactory as unknown as ReturnType; function makeMetadata(overrides: Partial = {}): IAudioMetadata { const base = { @@ -33,10 +49,19 @@ function makeMetadata(overrides: Partial = {}): IAudioMetadata { } as unknown as IAudioMetadata; } +function resetAudioMocks() { + vi.resetAllMocks(); + mockOpen.mockRejectedValue(new Error('no mediainfo file handle')); + mockMediaInfoFactory.mockResolvedValue({ + analyzeData: vi.fn().mockResolvedValue({ media: { track: [] } }), + close: vi.fn(), + }); +} + // ── AUTHOR / NARRATOR SPLIT ─────────────────────────────────────────────────── describe('extractAudioMetadata — author/narrator split', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); it('uses albumartist as author and artists as narrators', async () => { mockParseFile.mockResolvedValue( @@ -106,7 +131,7 @@ describe('extractAudioMetadata — author/narrator split', () => { // ── TITLE RESOLUTION ───────────────────────────────────────────────────────── describe('extractAudioMetadata — title', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); it('prefers album tag over track title', async () => { mockParseFile.mockResolvedValue( @@ -145,7 +170,7 @@ describe('extractAudioMetadata — title', () => { // ── DURATION ───────────────────────────────────────────────────────────────── describe('extractAudioMetadata — duration', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); it('rounds duration to integer seconds', async () => { mockParseFile.mockResolvedValue( @@ -168,7 +193,38 @@ describe('extractAudioMetadata — duration', () => { // ── CHAPTERS ───────────────────────────────────────────────────────────────── describe('extractAudioMetadata — chapters', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); + + it('prefers chapter markers extracted by mediainfo and sorts them by timestamp', async () => { + const close = vi.fn(); + const read = vi.fn().mockResolvedValue(undefined); + const stat = vi.fn().mockResolvedValue({ size: 4096 }); + mockOpen.mockResolvedValue({ stat, read, close }); + mockMediaInfoFactory.mockResolvedValue({ + analyzeData: vi.fn().mockResolvedValue({ + media: { + track: [ + { + '@type': 'Menu', + extra: { + _00_00_10_500: 'Chapter 2', + _00_00_00_000: 'Introduction', + }, + }, + ], + }, + }), + close: vi.fn(), + }); + mockParseFile.mockResolvedValue(makeMetadata()); + + const result = await extractAudioMetadata('/path/mediainfo.m4b'); + expect(result.chapters).toEqual([ + { title: 'Introduction', startMs: 0 }, + { title: 'Chapter 2', startMs: 10500 }, + ]); + expect(close).toHaveBeenCalledTimes(1); + }); it('converts chapter sampleOffset to startMs using sampleRate', async () => { mockParseFile.mockResolvedValue( @@ -214,7 +270,7 @@ describe('extractAudioMetadata — chapters', () => { // ── COVER ───────────────────────────────────────────────────────────────────── describe('extractAudioMetadata — cover', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); it('extracts cover bytes from embedded picture', async () => { const imageData = Buffer.from('fake-image-data'); @@ -241,7 +297,7 @@ describe('extractAudioMetadata — cover', () => { // ── OTHER FIELDS ───────────────────────────────────────────────────────────── describe('extractAudioMetadata — misc fields', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); it('maps publisher, publishedYear, description, language', async () => { mockParseFile.mockResolvedValue( @@ -261,12 +317,35 @@ describe('extractAudioMetadata — misc fields', () => { expect(result.description).toBe('An epic sci-fi audiobook'); expect(result.language).toBe('eng'); }); + + it('reads description from object comments and returns null when comment entries are blank', async () => { + mockParseFile.mockResolvedValueOnce( + makeMetadata({ + common: { + comment: [{ text: 'Object-based comment' }], + } as unknown as IAudioMetadata['common'], + }), + ); + mockParseFile.mockResolvedValueOnce( + makeMetadata({ + common: { + comment: [' ', { text: ' ' }], + } as unknown as IAudioMetadata['common'], + }), + ); + + const withObjectComment = await extractAudioMetadata('/path/object-comment.m4b'); + const withBlankComments = await extractAudioMetadata('/path/blank-comment.m4b'); + + expect(withObjectComment.description).toBe('Object-based comment'); + expect(withBlankComments.description).toBeNull(); + }); }); // ── FAILURE TOLERANCE ──────────────────────────────────────────────────────── describe('extractAudioMetadata — failure tolerance', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); it('returns safe empty result when parseFile throws', async () => { mockParseFile.mockRejectedValue(new Error('Cannot read file')); @@ -292,7 +371,7 @@ describe('extractAudioMetadata — failure tolerance', () => { // ── parseAudioDuration ──────────────────────────────────────────────────────── describe('parseAudioDuration', () => { - beforeEach(() => vi.resetAllMocks()); + beforeEach(() => resetAudioMocks()); it('returns rounded duration in seconds', async () => { mockParseFile.mockResolvedValue( diff --git a/server/src/modules/metadata/extractors/format-extractors.test.ts b/server/src/modules/metadata/extractors/format-extractors.test.ts new file mode 100644 index 000000000..704c33302 --- /dev/null +++ b/server/src/modules/metadata/extractors/format-extractors.test.ts @@ -0,0 +1,285 @@ +import type { MockedFunction } from 'vitest'; + +vi.mock('../lib/epub', () => ({ + extractEpubMetadata: vi.fn(), +})); + +vi.mock('../lib/cover-epub', () => ({ + extractEpubCover: vi.fn(), +})); + +vi.mock('../lib/fb2-parser', () => ({ + parseFb2File: vi.fn(), +})); + +vi.mock('../lib/cover-fb2', () => ({ + extractFb2Cover: vi.fn(), +})); + +vi.mock('../lib/cbz-metadata', () => ({ + extractCbzMetadata: vi.fn(), + extractCbrMetadata: vi.fn(), + extractCb7Metadata: vi.fn(), +})); + +vi.mock('../lib/cover-cbz', () => ({ + extractCbzCover: vi.fn(), +})); + +vi.mock('../lib/cover-cbr', () => ({ + extractCbrCover: vi.fn(), +})); + +vi.mock('../lib/cover-cb7', () => ({ + extractCb7Cover: vi.fn(), +})); + +vi.mock('../lib/filename-parser', () => ({ + parseBookFilename: vi.fn(), +})); + +vi.mock('../lib/mobi-parser', () => ({ + parseMobiFile: vi.fn(), + extractMobiCover: vi.fn(), +})); + +vi.mock('../lib/pdf-parser', () => ({ + parsePdfFile: vi.fn(), +})); + +vi.mock('./audio.extractor', () => ({ + extractAudioMetadata: vi.fn(), +})); + +import { extractEpubCover } from '../lib/cover-epub'; +import { extractFb2Cover } from '../lib/cover-fb2'; +import { extractCbrCover } from '../lib/cover-cbr'; +import { extractCbzCover } from '../lib/cover-cbz'; +import { extractCbrMetadata, extractCbzMetadata } from '../lib/cbz-metadata'; +import { parseBookFilename } from '../lib/filename-parser'; +import { parseFb2File } from '../lib/fb2-parser'; +import { extractEpubMetadata } from '../lib/epub'; +import { extractMobiCover, parseMobiFile } from '../lib/mobi-parser'; +import { parsePdfFile } from '../lib/pdf-parser'; +import { extractAudioMetadata } from './audio.extractor'; +import { AudioFormatExtractor } from './audio-format.extractor'; +import { ComicFormatExtractor } from './comic-format.extractor'; +import { EpubFormatExtractor } from './epub-format.extractor'; +import { Fb2FormatExtractor } from './fb2-format.extractor'; +import { MobiFormatExtractor } from './mobi-format.extractor'; +import { PdfFormatExtractor } from './pdf-format.extractor'; + +const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; +const mockExtractEpubCover = extractEpubCover as MockedFunction; +const mockParseFb2File = parseFb2File as MockedFunction; +const mockExtractFb2Cover = extractFb2Cover as MockedFunction; +const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; +const mockExtractCbrMetadata = extractCbrMetadata as MockedFunction; +const mockExtractCbzCover = extractCbzCover as MockedFunction; +const mockExtractCbrCover = extractCbrCover as MockedFunction; +const mockParseBookFilename = parseBookFilename as MockedFunction; +const mockParseMobiFile = parseMobiFile as MockedFunction; +const mockExtractMobiCover = extractMobiCover as MockedFunction; +const mockParsePdfFile = parsePdfFile as MockedFunction; +const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction; + +describe('metadata format extractors', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockParseBookFilename.mockReturnValue({ title: 'Fallback Title', publishedYear: 2001 }); + }); + + it('epub extractor returns null when metadata cannot be parsed', async () => { + mockExtractEpubMetadata.mockResolvedValue(null); + mockExtractEpubCover.mockResolvedValue(Buffer.from('cover')); + + await expect(new EpubFormatExtractor().extract('/books/test.epub')).resolves.toBeNull(); + }); + + it('epub extractor falls back to tags when genres are missing and tolerates cover extraction errors', async () => { + mockExtractEpubMetadata.mockResolvedValue({ + title: 'Dune', + subtitle: 'Book One', + description: null, + isbn10: null, + isbn13: null, + publisher: 'Ace', + publishedYear: 1965, + language: 'en', + seriesName: null, + seriesIndex: null, + authors: [{ name: 'Frank Herbert', sortName: null }], + genres: [], + tags: ['Science Fiction'], + }); + mockExtractEpubCover.mockRejectedValue(new Error('cover unavailable')); + + await expect(new EpubFormatExtractor().extract('/books/test.epub')).resolves.toEqual( + expect.objectContaining({ + title: 'Dune', + genres: ['Science Fiction'], + cover: null, + }), + ); + }); + + it('fb2 extractor maps parsed metadata and nulls cover when extraction fails', async () => { + mockParseFb2File.mockResolvedValue({ + title: 'Roadside Picnic', + description: 'Sci-fi classic', + publishedYear: 1972, + language: 'ru', + seriesName: null, + seriesIndex: null, + authors: [{ name: 'Arkady Strugatsky', sortName: null }], + genres: ['Science Fiction'], + }); + mockExtractFb2Cover.mockRejectedValue(new Error('missing binary')); + + await expect(new Fb2FormatExtractor().extract('/books/test.fb2')).resolves.toEqual( + expect.objectContaining({ + title: 'Roadside Picnic', + genres: ['Science Fiction'], + cover: null, + }), + ); + }); + + it('comic extractor uses filename fallback when embedded metadata is missing', async () => { + mockExtractCbzMetadata.mockResolvedValue(null); + mockExtractCbzCover.mockResolvedValue(Buffer.from('cover')); + mockParseBookFilename.mockReturnValue({ title: 'Saga Vol 1', publishedYear: 2014 }); + + await expect(new ComicFormatExtractor('cbz').extract('/books/saga.cbz')).resolves.toEqual( + expect.objectContaining({ + title: 'Saga Vol 1', + publishedYear: 2014, + authors: [], + genres: [], + }), + ); + }); + + it('comic extractor prefers explicit metadata fields and keeps comic metadata payload', async () => { + mockExtractCbrMetadata.mockResolvedValue({ + title: 'Batman', + description: 'Issue details', + publisher: 'DC', + publishedYear: 2020, + language: 'en', + seriesName: 'Batman', + seriesIndex: 55, + authors: [{ name: 'Writer', sortName: null }], + genres: ['Comics'], + tags: ['Superhero'], + comicMetadata: { issueNumber: '55', volumeName: 'Batman' }, + }); + mockExtractCbrCover.mockResolvedValue(null); + + await expect(new ComicFormatExtractor('cbr').extract('/books/batman.cbr')).resolves.toEqual( + expect.objectContaining({ + title: 'Batman', + genres: ['Comics'], + comicMetadata: { issueNumber: '55', volumeName: 'Batman' }, + }), + ); + }); + + it('mobi extractor parses year from publishedDate and tolerates missing cover', async () => { + mockParseMobiFile.mockResolvedValue({ + title: 'Snow Crash', + description: null, + isbn: '9780553380958', + publisher: 'Bantam', + publishedDate: '1992-06-01', + language: 'en', + authors: ['Neal Stephenson'], + tags: ['Cyberpunk'], + }); + mockExtractMobiCover.mockRejectedValue(new Error('cover unavailable')); + + await expect(new MobiFormatExtractor().extract('/books/snow-crash.mobi')).resolves.toEqual( + expect.objectContaining({ + publishedYear: 1992, + cover: null, + }), + ); + }); + + it('pdf extractor returns null for unparseable PDFs', async () => { + mockParsePdfFile.mockResolvedValue(null); + + await expect(new PdfFormatExtractor().extract('/books/test.pdf')).resolves.toBeNull(); + }); + + it('pdf extractor passes parser options and falls back to filename fields when title is missing', async () => { + const onWarning = vi.fn(); + mockParsePdfFile.mockResolvedValue({ + title: null, + subtitle: null, + description: null, + isbn10: null, + isbn13: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [], + genres: [], + tags: [], + rating: null, + googleBooksId: null, + goodreadsId: null, + amazonId: null, + hardcoverId: null, + openLibraryId: null, + itunesId: null, + coverBuffer: null, + pageCount: 320, + }); + mockParseBookFilename.mockReturnValue({ title: 'Filename Title', publishedYear: 2003 }); + + const result = await new PdfFormatExtractor({ extractCover: true, onWarning }).extract('/books/test.pdf'); + + expect(mockParsePdfFile).toHaveBeenCalledWith( + '/books/test.pdf', + expect.objectContaining({ + extractCover: true, + onWarning, + }), + ); + expect(result).toEqual( + expect.objectContaining({ + title: 'Filename Title', + publishedYear: 2003, + pageCount: 320, + }), + ); + }); + + it('audio extractor maps core metadata, narrators, duration, and chapters', async () => { + mockExtractAudioMetadata.mockResolvedValue({ + title: 'Project Hail Mary', + description: 'Audiobook', + publisher: 'Random House', + publishedYear: 2021, + language: 'en', + authors: [{ name: 'Andy Weir', sortName: null }], + narrators: ['Ray Porter'], + durationSeconds: 3600, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + coverBytes: Buffer.from('cover'), + }); + + await expect(new AudioFormatExtractor().extract('/books/hail-mary.m4b')).resolves.toEqual( + expect.objectContaining({ + title: 'Project Hail Mary', + narrators: ['Ray Porter'], + durationSeconds: 3600, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + cover: Buffer.from('cover'), + }), + ); + }); +}); diff --git a/server/src/modules/metadata/lib/pdf-cover.test.ts b/server/src/modules/metadata/lib/pdf-cover.test.ts new file mode 100644 index 000000000..0c96e3b87 --- /dev/null +++ b/server/src/modules/metadata/lib/pdf-cover.test.ts @@ -0,0 +1,52 @@ +import type { MockedFunction } from 'vitest'; + +vi.mock('fs/promises', () => ({ + mkdtemp: vi.fn(), + readFile: vi.fn(), + rm: vi.fn(), +})); + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})); + +import { execFile } from 'child_process'; +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { extractPdfCover } from './pdf-cover'; + +const mockExecFile = execFile as MockedFunction; +const mockMkdtemp = mkdtemp as MockedFunction; +const mockReadFile = readFile as MockedFunction; +const mockRm = rm as MockedFunction; + +describe('extractPdfCover', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMkdtemp.mockResolvedValue('/tmp/pdf-cover-abc'); + }); + + it('extracts first-page jpeg bytes and always cleans up temp directory', async () => { + const coverBytes = Buffer.from('cover-bytes'); + mockExecFile.mockImplementation((file, args, callback) => { + expect(file).toBe('pdftoppm'); + expect(args).toEqual(['-jpeg', '-singlefile', '-r', '150', '-f', '1', '-l', '1', '/books/test.pdf', '/tmp/pdf-cover-abc/cover']); + callback?.(null, '', ''); + return {} as never; + }); + mockReadFile.mockResolvedValue(coverBytes); + + await expect(extractPdfCover('/books/test.pdf')).resolves.toEqual(coverBytes); + expect(mockReadFile).toHaveBeenCalledWith('/tmp/pdf-cover-abc/cover.jpg'); + expect(mockRm).toHaveBeenCalledWith('/tmp/pdf-cover-abc', { recursive: true, force: true }); + }); + + it('cleans up temp directory even when pdftoppm fails', async () => { + mockExecFile.mockImplementation((_file, _args, callback) => { + callback?.(new Error('pdftoppm missing'), '', ''); + return {} as never; + }); + + await expect(extractPdfCover('/books/test.pdf')).rejects.toThrow('pdftoppm missing'); + expect(mockRm).toHaveBeenCalledWith('/tmp/pdf-cover-abc', { recursive: true, force: true }); + }); +}); diff --git a/server/src/modules/metadata/metadata-events.service.test.ts b/server/src/modules/metadata/metadata-events.service.test.ts new file mode 100644 index 000000000..5fed8311a --- /dev/null +++ b/server/src/modules/metadata/metadata-events.service.test.ts @@ -0,0 +1,13 @@ +import { METADATA_AUTHORS_REPLACED, MetadataEventsService } from './metadata-events.service'; + +describe('MetadataEventsService', () => { + it('emits author replacement events to subscribers', () => { + const events = new MetadataEventsService(); + const listener = vi.fn(); + events.on(METADATA_AUTHORS_REPLACED, listener); + + events.emit(METADATA_AUTHORS_REPLACED, { bookId: 10, authorIds: [2, 3] }); + + expect(listener).toHaveBeenCalledWith({ bookId: 10, authorIds: [2, 3] }); + }); +}); diff --git a/server/src/modules/metadata/metadata.service.test.ts b/server/src/modules/metadata/metadata.service.test.ts index 8103faa23..4edc0e0a8 100644 --- a/server/src/modules/metadata/metadata.service.test.ts +++ b/server/src/modules/metadata/metadata.service.test.ts @@ -81,9 +81,12 @@ import { Logger } from '@nestjs/common'; import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema'; import { generateThumbnail, imageExt } from './lib/cover'; +import { extractEpubCover } from './lib/cover-epub'; +import { extractEpubMetadata } from './lib/epub'; import { parseBookFilename } from './lib/filename-parser'; import { parseMobiFile } from './lib/mobi-parser'; import { parsePdfFile } from './lib/pdf-parser'; +import { extractAudioMetadata, parseAudioDuration } from './extractors/audio.extractor'; import { METADATA_AUTHORS_REPLACED } from './metadata-events.service'; import { MetadataService } from './metadata.service'; @@ -96,6 +99,10 @@ const mockImageExt = imageExt as MockedFunction; const mockParseBookFilename = parseBookFilename as MockedFunction; const mockParseMobiFile = parseMobiFile as MockedFunction; const mockParsePdfFile = parsePdfFile as MockedFunction; +const mockExtractEpubCover = extractEpubCover as MockedFunction; +const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; +const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction; +const mockParseAudioDuration = parseAudioDuration as MockedFunction; const makeDb = () => { const updateWhere = vi.fn().mockResolvedValue(undefined); @@ -154,20 +161,48 @@ describe('MetadataService', () => { mockParseBookFilename.mockReturnValue({ title: 'Fallback Title', publishedYear: 2001 }); mockParseMobiFile.mockResolvedValue(null); mockParsePdfFile.mockResolvedValue(null); + mockExtractEpubMetadata.mockResolvedValue(null); + mockExtractEpubCover.mockResolvedValue(null); + mockExtractAudioMetadata.mockResolvedValue({ + title: null, + authors: [], + narrators: [], + publisher: null, + publishedYear: null, + description: null, + language: null, + durationSeconds: null, + chapters: [], + coverBytes: null, + }); + mockParseAudioDuration.mockResolvedValue(null); }); - function makeService(db: unknown, metadataEvents?: unknown) { + function makeService( + db: unknown, + metadataEvents?: unknown, + overrides?: { + scoreService?: { calculateAndSave: ReturnType }; + narratorService?: { replaceForBook: ReturnType }; + comicMetadataRepository?: { upsert: ReturnType }; + bookMetadataLockService?: { + isFieldLocked: ReturnType; + filterAutomatedBookUpdate: ReturnType; + }; + embedder?: { embedBook: ReturnType } | null; + }, + ) { return new MetadataService( db as never, config as never, - { calculateAndSave: vi.fn().mockResolvedValue(undefined) } as never, - { replaceForBook: vi.fn().mockResolvedValue(undefined) } as never, - { upsert: vi.fn().mockResolvedValue(undefined) } as never, - { + (overrides?.scoreService ?? { calculateAndSave: vi.fn().mockResolvedValue(undefined) }) as never, + (overrides?.narratorService ?? { replaceForBook: vi.fn().mockResolvedValue(undefined) }) as never, + (overrides?.comicMetadataRepository ?? { upsert: vi.fn().mockResolvedValue(undefined) }) as never, + (overrides?.bookMetadataLockService ?? { isFieldLocked: vi.fn().mockResolvedValue(false), filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })), - } as never, - embedder as never, + }) as never, + (overrides?.embedder ?? embedder) as never, metadataEvents as never, ); } @@ -220,6 +255,38 @@ describe('MetadataService', () => { expect(mockWriteFile).not.toHaveBeenCalled(); }); + it('downloadAndSaveCover skips when cover is locked or HTTP response is not ok', async () => { + const { db } = makeDb(); + const lockService = { + isFieldLocked: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false), + filterAutomatedBookUpdate: vi.fn(), + }; + const service = makeService(db, undefined, { + bookMetadataLockService: lockService, + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + arrayBuffer: () => Promise.resolve(Buffer.from('ignored')), + }) as never; + + await expect(service.downloadAndSaveCover('https://img.example/locked.png', 4)).resolves.toBe(false); + await expect(service.downloadAndSaveCover('https://img.example/not-found.png', 4)).resolves.toBe(false); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it('extractAndSave short-circuits for unsupported formats and empty parser output', async () => { + const { db } = makeDb(); + const service = makeService(db); + + await expect(service.extractAndSave(1, '/tmp/book.unknown', 'unknown')).resolves.toBeUndefined(); + + mockParsePdfFile.mockResolvedValueOnce(null); + await expect(service.extractAndSave(2, '/tmp/book.pdf', 'pdf')).resolves.toBeUndefined(); + expect(db.update).not.toHaveBeenCalled(); + }); + it('refreshCoverForBook returns false and avoids db writes when extractor reports no cover', async () => { const { db } = makeDb(); const service = makeService(db); @@ -228,6 +295,51 @@ describe('MetadataService', () => { expect(db.update).not.toHaveBeenCalled(); }); + it('refreshCoverForBook handles missing extractors, locked cover field, and successful refresh', async () => { + const { db, updateSet } = makeDb(); + const lockService = { + isFieldLocked: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false), + filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })), + }; + const service = makeService(db, undefined, { + bookMetadataLockService: lockService, + }); + + await expect(service.refreshCoverForBook(7, '/book.unknown', 'unknown')).resolves.toBe(false); + + await expect(service.refreshCoverForBook(7, '/book.epub', 'epub')).resolves.toBe(false); + + mockExtractEpubMetadata.mockResolvedValueOnce({ + title: 'Refreshable book', + subtitle: null, + description: null, + isbn10: null, + isbn13: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [], + genres: [], + tags: [], + rating: null, + pageCount: null, + googleBooksId: null, + goodreadsId: null, + amazonId: null, + hardcoverId: null, + openLibraryId: null, + itunesId: null, + coverBuffer: null, + }); + mockExtractEpubCover.mockResolvedValueOnce(Buffer.from('epub-cover-2')); + await expect(service.refreshCoverForBook(8, '/book2.epub', 'epub')).resolves.toBe(true); + + expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/8/cover_extracted.png', expect.any(Buffer)); + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ coverSource: 'extracted' })); + }); + it('extractAndSave propagates extractor errors', async () => { const { db } = makeDb(); mockParsePdfFile.mockRejectedValue(new Error('bad metadata')); @@ -236,6 +348,54 @@ describe('MetadataService', () => { await expect(service.extractAndSave(15, '/books/a.pdf', 'pdf')).rejects.toThrow('bad metadata'); }); + it('extractAndSave logs warning when score calculation or embedding fails', async () => { + const { db } = makeDb(); + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const scoreService = { + calculateAndSave: vi.fn().mockRejectedValue(new Error('score failed')), + }; + const failingEmbedder = { + embedBook: vi.fn().mockRejectedValue(new Error('embed failed')), + }; + const service = makeService(db, undefined, { + scoreService, + embedder: failingEmbedder, + }); + + mockParsePdfFile.mockResolvedValue({ + title: 'Warn book', + subtitle: null, + description: null, + isbn10: null, + isbn13: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [], + genres: [], + tags: [], + rating: null, + pageCount: null, + googleBooksId: null, + goodreadsId: null, + amazonId: null, + hardcoverId: null, + openLibraryId: null, + itunesId: null, + coverBuffer: null, + }); + + await service.extractAndSave(44, '/tmp/warn-book.pdf', 'pdf'); + await Promise.resolve(); + + expect(scoreService.calculateAndSave).toHaveBeenCalledWith(44); + expect(failingEmbedder.embedBook).toHaveBeenCalledWith(44); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.score_calculation] [fail]')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.embedding] [fail]')); + }); + it('extractAndSave(pdf) surfaces parser warnings in logs', async () => { const { db } = makeDb(); const service = makeService(db); @@ -568,6 +728,174 @@ describe('MetadataService', () => { expect(insertedTags).toEqual(['Shelf', 'Y'.repeat(200)]); }); + it('extractAudioChaptersAndNarrators updates filtered audio fields and supports early exits', async () => { + const { db, updateSet } = makeDb(); + const narratorService = { + replaceForBook: vi.fn().mockResolvedValue(undefined), + }; + const lockService = { + isFieldLocked: vi.fn().mockResolvedValue(false), + filterAutomatedBookUpdate: vi.fn().mockResolvedValue({ + dto: { + audioMetadata: { + chapters: [{ title: 'Chapter 1', start: 0, end: 10 }], + narrators: [{ name: 'Narrator A', sortName: null }], + }, + }, + skippedFields: [], + }), + }; + const service = makeService(db, undefined, { + narratorService, + bookMetadataLockService: lockService, + }); + + mockExtractAudioMetadata.mockResolvedValueOnce({ + title: null, + authors: [], + narrators: [{ name: 'Narrator A', sortName: null }], + publisher: null, + publishedYear: null, + description: null, + language: null, + durationSeconds: null, + chapters: [{ title: 'Chapter 1', start: 0, end: 10 }], + coverBytes: null, + }); + await service.extractAudioChaptersAndNarrators(70, '/tmp/audio.m4b', 'm4b'); + + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + chapters: [{ title: 'Chapter 1', start: 0, end: 10 }], + updatedAt: expect.any(Date), + }), + ); + expect(narratorService.replaceForBook).toHaveBeenCalledWith(70, [{ name: 'Narrator A', sortName: null }]); + + await expect(service.extractAudioChaptersAndNarrators(71, '/tmp/audio.unknown', 'unknown')).resolves.toBeUndefined(); + + await expect(service.extractAudioChaptersAndNarrators(72, '/tmp/book.pdf', 'pdf')).resolves.toBeUndefined(); + }); + + it('extractAudioFileDuration writes duration only when parser returns a value', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db); + + mockParseAudioDuration.mockResolvedValueOnce(null).mockResolvedValueOnce(4321); + await service.extractAudioFileDuration(99, '/tmp/book.m4b'); + await service.extractAudioFileDuration(99, '/tmp/book.m4b'); + + expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 4321 }); + }); + + it('replaceNarrators and upsertComicMetadata delegate to collaborators', async () => { + const { db } = makeDb(); + const narratorService = { + replaceForBook: vi.fn().mockResolvedValue(undefined), + }; + const comicMetadataRepository = { + upsert: vi.fn().mockResolvedValue(undefined), + }; + const service = makeService(db, undefined, { + narratorService, + comicMetadataRepository, + }); + + await service.replaceNarrators(22, [{ name: 'N1', sortName: null }]); + await service.upsertComicMetadata(22, { issueNumber: 3 }); + + expect(narratorService.replaceForBook).toHaveBeenCalledWith(22, [{ name: 'N1', sortName: null }]); + expect(comicMetadataRepository.upsert).toHaveBeenCalledWith(22, { issueNumber: 3 }); + }); + + it('replaceGenres and replaceTags use provided executors and resolve unresolved relation names', async () => { + const { db, transaction } = makeDb(); + const service = makeService(db); + + const genreDeleteWhere = vi.fn().mockResolvedValue(undefined); + const tagDeleteWhere = vi.fn().mockResolvedValue(undefined); + const bookGenreLinks: Array<{ bookId: number; genreId: number }> = []; + const bookTagLinks: Array<{ bookId: number; tagId: number }> = []; + + const executor = { + delete: vi.fn((table: unknown) => { + if (table === bookGenres) return { where: genreDeleteWhere }; + if (table === bookTags) return { where: tagDeleteWhere }; + throw new Error('unexpected table in delete'); + }), + insert: vi.fn((table: unknown) => { + if (table === genres) { + return { + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Promise.resolve([]), + }), + }), + }; + } + if (table === tags) { + return { + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Promise.resolve([]), + }), + }), + }; + } + if (table === bookGenres) { + return { + values: (rows: Array<{ bookId: number; genreId: number }>) => { + bookGenreLinks.push(...rows); + return { onConflictDoNothing: () => Promise.resolve(undefined) }; + }, + }; + } + if (table === bookTags) { + return { + values: (rows: Array<{ bookId: number; tagId: number }>) => { + bookTagLinks.push(...rows); + return { onConflictDoNothing: () => Promise.resolve(undefined) }; + }, + }; + } + throw new Error('unexpected table in insert'); + }), + select: vi.fn((fields: Record) => { + const hasGenreName = Object.values(fields).includes(genres.name); + return { + from: () => ({ + where: () => (hasGenreName ? Promise.resolve([{ id: 501, name: 'Mystery' }]) : Promise.resolve([{ id: 601, name: 'Shelf' }])), + }), + }; + }), + }; + + await service.replaceGenres(41, ['Mystery'], { executor: executor as never }); + await service.replaceTags(41, ['Shelf'], { executor: executor as never }); + + expect(transaction).not.toHaveBeenCalled(); + expect(genreDeleteWhere).toHaveBeenCalledTimes(1); + expect(tagDeleteWhere).toHaveBeenCalledTimes(1); + expect(bookGenreLinks).toEqual([{ bookId: 41, genreId: 501 }]); + expect(bookTagLinks).toEqual([{ bookId: 41, tagId: 601 }]); + }); + + it('logs buffered-large-pdf warnings with size metadata', () => { + const service = makeService(makeDb().db); + const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + (service as any).logPdfParseWarning({ + code: 'buffered-large-pdf', + absolutePath: '/tmp/large.pdf', + sizeBytes: 10_000_000, + thresholdBytes: 5_000_000, + errorClass: 'None', + errorMessage: 'none', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('large pdf buffered in memory')); + }); + it('aggregateAudioDuration sums only files that match the selected primary audio format', async () => { const primaryWhere = vi.fn().mockResolvedValue([{ format: 'm4b' }]); const primaryInnerJoin = vi.fn().mockReturnValue({ where: primaryWhere }); diff --git a/server/src/modules/migration/adapters/booklore/booklore-connector.test.ts b/server/src/modules/migration/adapters/booklore/booklore-connector.test.ts index 55b0d805d..0f7a39388 100644 --- a/server/src/modules/migration/adapters/booklore/booklore-connector.test.ts +++ b/server/src/modules/migration/adapters/booklore/booklore-connector.test.ts @@ -1,8 +1,61 @@ +vi.mock('mysql2/promise', () => { + const createConnection = vi.fn(); + return { + default: { + createConnection, + }, + }; +}); + import { describe, expect, it, vi } from 'vitest'; +import mysql from 'mysql2/promise'; import { BookloreConnector } from './booklore-connector'; describe('BookloreConnector', () => { + it('opens a connection, runs callback, and always closes connection', async () => { + const connector = new BookloreConnector(); + const end = vi.fn().mockResolvedValue(undefined); + const connection = { end } as never; + const createConnection = mysql.createConnection as unknown as ReturnType; + createConnection.mockResolvedValue(connection); + + await expect( + connector.withConnection({ host: 'localhost', user: 'root', password: '', database: 'booklore', ssl: false } as never, () => + Promise.resolve('ok'), + ), + ).resolves.toBe('ok'); + await expect( + connector.withConnection({ host: 'localhost', user: 'root', password: '', database: 'booklore', ssl: false } as never, () => + Promise.reject(new Error('boom')), + ), + ).rejects.toThrow('boom'); + + expect(createConnection).toHaveBeenCalledTimes(2); + expect(end).toHaveBeenCalledTimes(2); + }); + + it('lists tables and columns as lowercase names', async () => { + const connector = new BookloreConnector(); + const conn = { + query: vi + .fn() + .mockResolvedValueOnce([[{ tableName: 'Book' }, { tableName: 'BOOK_FILE' }]]) + .mockResolvedValueOnce([[{ columnName: 'ID' }, { columnName: 'Book_ID' }]]), + }; + + await expect(connector.listTables(conn as never)).resolves.toEqual(new Set(['book', 'book_file'])); + await expect(connector.listColumns(conn as never, 'book_file')).resolves.toEqual(new Set(['id', 'book_id'])); + expect(conn.query).toHaveBeenLastCalledWith( + ` + SELECT column_name AS columnName + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? + `, + ['book_file'], + ); + }); + it('counts live Booklore migration tables', async () => { const connector = new BookloreConnector(); const conn = { @@ -22,4 +75,19 @@ describe('BookloreConnector', () => { await expect(connector.countRows(conn as never, 'book_files')).rejects.toThrow('allowed migration table list'); expect(conn.query).not.toHaveBeenCalled(); }); + + it('normalizes non-numeric count values and proxies raw query rows', async () => { + const connector = new BookloreConnector(); + const conn = { + query: vi + .fn() + .mockResolvedValueOnce([[{ count: '7' }]]) + .mockResolvedValueOnce([[{ count: 'not-a-number' }]]) + .mockResolvedValueOnce([[{ id: 1 }]]), + }; + + await expect(connector.countRows(conn as never, 'book_file')).resolves.toBe(7); + await expect(connector.countRows(conn as never, 'book_file')).resolves.toBe(0); + await expect(connector.queryRows(conn as never, 'SELECT * FROM book WHERE id = ?', [1])).resolves.toEqual([{ id: 1 }]); + }); }); diff --git a/server/src/modules/migration/adapters/booklore/booklore-source.adapter.test.ts b/server/src/modules/migration/adapters/booklore/booklore-source.adapter.test.ts index f1242265a..2eb819575 100644 --- a/server/src/modules/migration/adapters/booklore/booklore-source.adapter.test.ts +++ b/server/src/modules/migration/adapters/booklore/booklore-source.adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BookloreSourceAdapter } from './booklore-source.adapter'; +import { BOOKLORE_TABLES } from './booklore-tables'; describe('BookloreSourceAdapter shelf export', () => { it('carries the shelf owner through each shelf-book mapping', async () => { @@ -84,3 +85,278 @@ describe('BookloreSourceAdapter table resolution', () => { expect(connector.countRows).toHaveBeenCalledWith(expect.anything(), 'ebook_viewer_preference'); }); }); + +describe('BookloreSourceAdapter validation and export', () => { + it('reports missing required core tables and marks validation as failed', async () => { + const connector = { + withConnection: vi.fn().mockImplementation((_config, fn) => fn({})), + listTables: vi.fn().mockResolvedValue(new Set([BOOKLORE_TABLES.users, BOOKLORE_TABLES.book])), + countRows: vi.fn().mockResolvedValue(1), + queryRows: vi.fn().mockResolvedValue([{ version: '11.4.5' }]), + }; + + const adapter = new BookloreSourceAdapter(connector as never); + const result = await adapter.validate({ + host: 'localhost', + port: 3306, + user: 'booklore', + password: '', + database: 'booklore', + mediaRootPath: null, + ssl: false, + }); + + expect(result.ok).toBe(false); + expect(result.missingTables).toEqual(['book_file']); + expect(result.warnings).toEqual(expect.arrayContaining(['mediaRootPath not configured; book cover/thumbnail import disabled'])); + }); + + it('exports source users/books/user-state/shelves and resolves contributors, tags, and genres', async () => { + const columns = (...names: string[]) => new Set(names.map((name) => name.toLowerCase())); + const connector = { + withConnection: vi.fn().mockImplementation((_config, fn) => fn({})), + listTables: vi + .fn() + .mockResolvedValue( + new Set([ + BOOKLORE_TABLES.users, + BOOKLORE_TABLES.book, + BOOKLORE_TABLES.bookFile, + BOOKLORE_TABLES.bookMetadata, + BOOKLORE_TABLES.libraryPath, + BOOKLORE_TABLES.author, + BOOKLORE_TABLES.bookMetadataAuthorMapping, + BOOKLORE_TABLES.userBookProgress, + BOOKLORE_TABLES.userBookFileProgress, + BOOKLORE_TABLES.bookMarks, + BOOKLORE_TABLES.annotations, + BOOKLORE_TABLES.shelf, + BOOKLORE_TABLES.bookShelfMapping, + BOOKLORE_TABLES.category, + BOOKLORE_TABLES.bookMetadataCategoryMapping, + BOOKLORE_TABLES.tag, + BOOKLORE_TABLES.bookMetadataTagMapping, + ]), + ), + listColumns: vi.fn().mockImplementation((_conn, tableName: string) => { + const map: Record> = { + [BOOKLORE_TABLES.users]: columns('id', 'username', 'name', 'email'), + [BOOKLORE_TABLES.book]: columns('id', 'library_path_id', 'deleted'), + [BOOKLORE_TABLES.bookMetadata]: columns( + 'book_id', + 'title', + 'author', + 'subtitle', + 'isbn10', + 'isbn13', + 'description', + 'publisher', + 'published_year', + 'language', + 'page_count', + 'series_name', + 'series_index', + 'rating', + 'google_books_id', + 'goodreads_id', + 'amazon_id', + 'hardcover_id', + 'audible_id', + 'comicvine_id', + 'duration_seconds', + 'abridged', + 'narrator', + ), + [BOOKLORE_TABLES.bookFile]: columns('id', 'book_id', 'file_name', 'file_sub_path', 'current_hash', 'duration_seconds', 'is_book'), + [BOOKLORE_TABLES.libraryPath]: columns('id', 'path'), + [BOOKLORE_TABLES.author]: columns('id', 'name', 'sort_name', 'description'), + [BOOKLORE_TABLES.bookMetadataAuthorMapping]: columns('book_id', 'author_id', 'sort_order'), + [BOOKLORE_TABLES.userBookProgress]: columns('user_id', 'book_id', 'status', 'percentage', 'updated_at'), + [BOOKLORE_TABLES.userBookFileProgress]: columns( + 'user_id', + 'book_id', + 'book_file_id', + 'percentage', + 'cfi', + 'page_number', + 'position_seconds', + 'updated_at', + ), + [BOOKLORE_TABLES.bookMarks]: columns('user_id', 'book_id', 'book_file_id', 'title', 'cfi', 'position_seconds', 'track_index', 'created_at'), + [BOOKLORE_TABLES.annotations]: columns( + 'user_id', + 'book_id', + 'cfi', + 'text', + 'color', + 'style', + 'note', + 'chapter_title', + 'created_at', + 'updated_at', + ), + [BOOKLORE_TABLES.shelf]: columns('id', 'user_id', 'name'), + [BOOKLORE_TABLES.bookShelfMapping]: columns('shelf_id', 'book_id'), + [BOOKLORE_TABLES.category]: columns('id', 'name'), + [BOOKLORE_TABLES.bookMetadataCategoryMapping]: columns('book_id', 'category_id'), + [BOOKLORE_TABLES.tag]: columns('id', 'name'), + [BOOKLORE_TABLES.bookMetadataTagMapping]: columns('book_id', 'tag_id'), + }; + return Promise.resolve(map[tableName] ?? new Set()); + }), + queryRows: vi.fn().mockImplementation((_conn, sqlText: string) => { + if (sqlText.includes('FROM `users` u')) { + return Promise.resolve([{ sourceUserId: 'u1', username: 'reader', name: 'Reader', email: 'reader@example.com' }]); + } + if (sqlText.includes('FROM `book` b')) { + return Promise.resolve([ + { + sourceBookId: 'b1', + title: 'Dune', + author: 'Frank Herbert', + subtitle: null, + isbn10: '0441172717', + isbn13: '9780441172719', + description: 'Classic sci-fi', + publisher: 'Ace', + publishedYear: 1965, + publishedDate: null, + language: 'en', + pageCount: 412, + seriesName: null, + seriesIndex: null, + rating: 4, + googleBooksId: 'google-1', + goodreadsId: 'goodreads-1', + amazonId: 'amazon-1', + hardcoverId: 'hardcover-1', + audibleId: 'audible-1', + comicvineId: null, + durationSeconds: 1234, + abridged: 0, + narrator: '["Narrator A","Narrator A","Narrator B"]', + sourceFileId: 'file-1', + directFilePath: null, + fileName: 'dune.epub', + fileSubPath: 'SciFi', + currentHash: 'hash-1', + initialHash: null, + fileDurationSeconds: 1234, + libraryRootPath: '/library', + }, + ]); + } + if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.bookMetadataAuthorMapping}\` m`) && sqlText.includes(`JOIN \`${BOOKLORE_TABLES.author}\` a`)) { + return Promise.resolve([ + { bookId: 'b1', sourceContributorId: 'a2', name: 'Brian Herbert', sortName: 'Herbert, Brian', description: null, displayOrder: 2 }, + { bookId: 'b1', sourceContributorId: 'a1', name: 'Frank Herbert', sortName: 'Herbert, Frank', description: null, displayOrder: 1 }, + ]); + } + if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.userBookProgress}\` p`)) { + return Promise.resolve([ + { sourceUserId: 'u1', sourceBookId: 'b1', status: 'completed', percentage: 100, startedAt: null, finishedAt: null, updatedAt: null }, + ]); + } + if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.userBookFileProgress}\` p`)) { + return Promise.resolve([ + { + sourceUserId: 'u1', + sourceBookId: 'b1', + sourceFileId: 'file-1', + percentage: 75, + cfi: '/6/2', + href: null, + pageNumber: 120, + positionSeconds: 33, + updatedAt: null, + }, + ]); + } + if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.bookMarks}\` b`)) { + return Promise.resolve([ + { + sourceUserId: 'u1', + sourceBookId: 'b1', + sourceFileId: 'file-1', + title: 'bookmark', + cfi: '/6/2', + positionSeconds: 42, + trackIndex: 1, + createdAt: null, + }, + ]); + } + if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.annotations}\` a`)) { + return Promise.resolve([ + { + sourceUserId: 'u1', + sourceBookId: 'b1', + cfi: '/6/2', + text: 'highlight', + color: 'yellow', + style: 'highlight', + note: 'note', + chapterTitle: 'Chapter 1', + createdAt: null, + updatedAt: null, + }, + ]); + } + if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.shelf}\` s`)) { + return Promise.resolve([{ sourceShelfId: 's1', sourceUserId: 'u1', name: 'Favorites' }]); + } + if (sqlText.includes(`FROM \`${BOOKLORE_TABLES.bookShelfMapping}\` m`)) { + return Promise.resolve([{ sourceShelfId: 's1', sourceUserId: 'u1', sourceBookId: 'b1' }]); + } + if (sqlText.includes(`JOIN \`${BOOKLORE_TABLES.category}\` c`)) { + return Promise.resolve([{ bookId: 'b1', name: 'Science Fiction' }]); + } + if (sqlText.includes(`JOIN \`${BOOKLORE_TABLES.tag}\` t`)) { + return Promise.resolve([{ bookId: 'b1', name: 'Space Opera' }]); + } + return Promise.resolve([]); + }), + }; + + const adapter = new BookloreSourceAdapter(connector as never); + const result = await adapter.exportData({ + host: 'localhost', + port: 3306, + user: 'booklore', + password: 'secret', + database: 'booklore', + mediaRootPath: '/media', + ssl: false, + }); + + expect(result.availableDomains).toEqual( + expect.objectContaining({ + metadata: true, + authors: true, + narrators: true, + genres: true, + tags: true, + userBookStatuses: true, + readingProgress: true, + bookmarks: true, + annotations: true, + shelves: true, + covers: true, + }), + ); + expect(result.users).toEqual([{ sourceUserId: 'u1', username: 'reader', name: 'Reader', email: 'reader@example.com' }]); + expect(result.books[0]).toEqual( + expect.objectContaining({ + sourceBookId: 'b1', + filePath: '/library/SciFi/dune.epub', + fileHash: 'hash-1', + narrators: [expect.objectContaining({ name: 'Narrator A' }), expect.objectContaining({ name: 'Narrator B' })], + genres: ['Science Fiction'], + tags: ['Space Opera'], + }), + ); + expect(result.books[0]?.authors?.map((author) => author.name)).toEqual(['Frank Herbert', 'Brian Herbert']); + expect(result.shelves).toEqual([{ sourceShelfId: 's1', sourceUserId: 'u1', name: 'Favorites' }]); + expect(result.shelfBooks).toEqual([{ sourceShelfId: 's1', sourceUserId: 'u1', sourceBookId: 'b1' }]); + }); +}); diff --git a/server/src/modules/migration/adapters/source-adapter.registry.test.ts b/server/src/modules/migration/adapters/source-adapter.registry.test.ts new file mode 100644 index 000000000..e97452221 --- /dev/null +++ b/server/src/modules/migration/adapters/source-adapter.registry.test.ts @@ -0,0 +1,36 @@ +import { BadRequestException } from '@nestjs/common'; + +import { SourceAdapterRegistry } from './source-adapter.registry'; + +describe('SourceAdapterRegistry', () => { + function makeRegistry() { + const bookloreAdapter = { + type: 'booklore', + validate: vi.fn(), + snapshot: vi.fn(), + exportData: vi.fn(), + }; + return { + registry: new SourceAdapterRegistry(bookloreAdapter as never), + bookloreAdapter, + }; + } + + it('lists supported source types in sorted order', () => { + const { registry } = makeRegistry(); + expect(registry.listTypes()).toEqual(['booklore']); + }); + + it('retrieves adapter by normalized source type', () => { + const { registry, bookloreAdapter } = makeRegistry(); + + expect(registry.get('booklore')).toBe(bookloreAdapter); + expect(registry.get(' BOOKLORE ')).toBe(bookloreAdapter); + }); + + it('throws BadRequestException for unsupported source types', () => { + const { registry } = makeRegistry(); + + expect(() => registry.get('unknown')).toThrow(BadRequestException); + }); +}); diff --git a/server/src/modules/migration/dto/migration-dto-validation.test.ts b/server/src/modules/migration/dto/migration-dto-validation.test.ts new file mode 100644 index 000000000..59a242f2f --- /dev/null +++ b/server/src/modules/migration/dto/migration-dto-validation.test.ts @@ -0,0 +1,72 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateDryRunPlanDto } from './create-dry-run-plan.dto'; +import { CreateMigrationProfileDto } from './create-migration-profile.dto'; +import { ResolveDuplicateMatchesDto } from './resolve-duplicate-matches.dto'; +import { StartLiveRunDto } from './start-live-run.dto'; +import { ValidatePathMappingsDto } from './validate-path-mappings.dto'; + +async function errorsFor(cls: new () => T, value: Record) { + const dto = plainToInstance(cls, value); + return validate(dto); +} + +describe('Migration DTO validation', () => { + it('validates migration profile creation payloads with nested mappings', async () => { + expect( + ( + await errorsFor(CreateMigrationProfileDto, { + sourceId: '5', + name: 'Booklore Import', + userMappings: [{ sourceUserId: 'u1', targetUserId: '10' }], + pathMappings: [{ sourcePrefix: '/old/books', targetPrefix: '/new/books' }], + scope: { metadata: true }, + }) + ).length, + ).toBe(0); + + expect((await errorsFor(CreateMigrationProfileDto, { sourceId: 0, name: '', userMappings: [] })).length).toBeGreaterThan(0); + expect( + (await errorsFor(CreateMigrationProfileDto, { sourceId: 2, name: 'ok', userMappings: [{ sourceUserId: '', targetUserId: 1 }] })).length, + ).toBeGreaterThan(0); + }); + + it('validates dry-run and live-run identifiers and optional settings', async () => { + expect((await errorsFor(CreateDryRunPlanDto, { profileId: '4', scopeOverride: { tags: false } })).length).toBe(0); + expect((await errorsFor(StartLiveRunDto, { planArtifactId: '9', targetKey: 'default-target' })).length).toBe(0); + + expect((await errorsFor(CreateDryRunPlanDto, { profileId: 0 })).length).toBeGreaterThan(0); + expect((await errorsFor(StartLiveRunDto, { planArtifactId: 0 })).length).toBeGreaterThan(0); + expect((await errorsFor(StartLiveRunDto, { planArtifactId: 9, targetKey: 'x'.repeat(101) })).length).toBeGreaterThan(0); + }); + + it('validates duplicate resolution payload structure', async () => { + expect( + ( + await errorsFor(ResolveDuplicateMatchesDto, { + resolutions: [{ targetBookId: 42, selectedSourceBookId: 'source-abc' }], + }) + ).length, + ).toBe(0); + + expect((await errorsFor(ResolveDuplicateMatchesDto, { resolutions: [] })).length).toBeGreaterThan(0); + expect((await errorsFor(ResolveDuplicateMatchesDto, { resolutions: [{ targetBookId: 0, selectedSourceBookId: '' }] })).length).toBeGreaterThan(0); + }); + + it('validates path mapping checks and sample limits', async () => { + expect( + ( + await errorsFor(ValidatePathMappingsDto, { + pathMappings: [{ sourcePrefix: '/source', targetPrefix: '/target' }], + sampleLimit: '50', + }) + ).length, + ).toBe(0); + + expect((await errorsFor(ValidatePathMappingsDto, { pathMappings: [{ sourcePrefix: '', targetPrefix: '/target' }] })).length).toBeGreaterThan(0); + expect((await errorsFor(ValidatePathMappingsDto, { pathMappings: [], sampleLimit: 101 })).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/migration/executor/cover.importer.test.ts b/server/src/modules/migration/executor/cover.importer.test.ts new file mode 100644 index 000000000..72e7fc281 --- /dev/null +++ b/server/src/modules/migration/executor/cover.importer.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, readdir, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { CoverImporter } from './cover.importer'; + +function makeImporter() { + const repo = { + setRunMetric: vi.fn().mockResolvedValue(undefined), + }; + const importRepo = { + fetchLibraryIdsByBookIds: vi.fn().mockResolvedValue(new Map()), + markCoverAsCustom: vi.fn().mockResolvedValue(undefined), + }; + const scanGateway = { + emitCoverRefreshed: vi.fn(), + }; + const importer = new CoverImporter(repo as never, importRepo as never, scanGateway as never); + return { importer, repo, importRepo, scanGateway }; +} + +describe('CoverImporter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('counts rejected per-book tasks as failed while continuing batch processing', async () => { + const { importer, repo, importRepo, scanGateway } = makeImporter(); + importRepo.fetchLibraryIdsByBookIds.mockResolvedValue(new Map([[901, 12]])); + const processSingleMatch = vi + .spyOn(importer as any, 'processSingleMatch') + .mockResolvedValueOnce('imported') + .mockRejectedValueOnce(new Error('permission denied')); + + await importer.import( + 41, + { + execution: { + matchedBooks: [ + { sourceBookId: 'source-1', targetBookId: 901 }, + { sourceBookId: 'source-2', targetBookId: 902 }, + ], + }, + } as never, + '/app', + '/source-media', + vi.fn().mockResolvedValue(undefined), + ); + + expect(processSingleMatch).toHaveBeenCalledTimes(2); + expect(scanGateway.emitCoverRefreshed).toHaveBeenCalledWith({ bookId: 901, libraryId: 12 }); + expect(repo.setRunMetric).toHaveBeenCalledWith( + 41, + 'book_covers', + 'book_covers', + expect.objectContaining({ + processed: 2, + imported: 1, + failed: 1, + }), + ); + }); + + it('returns failed when importing a single cover throws after reading source cover bytes', async () => { + const { importer } = makeImporter(); + vi.spyOn(importer as any, 'readOptionalFile').mockResolvedValue(Buffer.from('cover-bytes')); + vi.spyOn(importer as any, 'importSingleCover').mockRejectedValue(new Error('invalid image payload')); + + await expect((importer as any).processSingleMatch(99, { sourceBookId: 'source-1', targetBookId: 901 }, '/app', '/source-media')).resolves.toBe( + 'failed', + ); + }); + + it('readOptionalFile returns null for ENOENT and rethrows non-ENOENT read errors', async () => { + const { importer } = makeImporter(); + const tempRoot = await mkdtemp(join(tmpdir(), 'cover-importer-read-')); + try { + await expect((importer as any).readOptionalFile(join(tempRoot, 'missing.jpg'))).resolves.toBeNull(); + await expect((importer as any).readOptionalFile(tempRoot)).rejects.toBeInstanceOf(Error); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('deleteFilesByPrefix removes only matching files and keeps unrelated entries', async () => { + const { importer } = makeImporter(); + const tempRoot = await mkdtemp(join(tmpdir(), 'cover-importer-delete-')); + try { + await writeFile(join(tempRoot, 'cover_custom.jpg'), 'cover'); + await writeFile(join(tempRoot, 'cover_custom.png'), 'cover'); + await writeFile(join(tempRoot, 'thumbnail.jpg'), 'thumb'); + + await (importer as any).deleteFilesByPrefix(tempRoot, 'cover_custom.'); + const remaining = await readdir(tempRoot); + + expect(remaining).toEqual(['thumbnail.jpg']); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('readDirIfExists/removeFileIfPresent handle ENOENT and still surface other fs errors', async () => { + const { importer } = makeImporter(); + const tempRoot = await mkdtemp(join(tmpdir(), 'cover-importer-fs-')); + const existingFile = join(tempRoot, 'existing.txt'); + await writeFile(existingFile, 'x'); + + try { + await expect((importer as any).readDirIfExists(join(tempRoot, 'missing-dir'))).resolves.toEqual([]); + await expect((importer as any).removeFileIfPresent(join(tempRoot, 'missing.txt'))).resolves.toBeUndefined(); + + await expect((importer as any).readDirIfExists(existingFile)).rejects.toBeInstanceOf(Error); + await expect((importer as any).removeFileIfPresent(tempRoot)).rejects.toBeInstanceOf(Error); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/modules/migration/executor/executor-utils.test.ts b/server/src/modules/migration/executor/executor-utils.test.ts new file mode 100644 index 000000000..95754e54e --- /dev/null +++ b/server/src/modules/migration/executor/executor-utils.test.ts @@ -0,0 +1,174 @@ +import { + buildContributorValues, + buildMetadataPatch, + buildSourceFileTargetMap, + clampNonNegative, + clampPercent, + emptyCounters, + getSourceContributors, + hasErrorCode, + normalizeReadStatus, + pruneUndefined, + toDate, + truncateNullableText, + truncateText, + uniqueNumbers, +} from './executor-utils'; + +describe('migration executor utils', () => { + it('returns zeroed stage counters and unique finite numbers', () => { + expect(emptyCounters()).toEqual({ processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 }); + expect(uniqueNumbers([1, 2, 2, Number.NaN, Infinity, 3])).toEqual([1, 2, 3]); + }); + + it('maps source files to target file ids using unique hash and mapped path fallback', () => { + const planned = { + execution: { + sourceData: { + books: [ + { + sourceBookId: 's1', + files: [ + { sourceFileId: 'f-hash', fileHash: 'hash-1', filePath: '/source/hash.epub' }, + { sourceFileId: 'f-path', fileHash: null, filePath: '/source/path.epub' }, + { sourceFileId: 'f-ambiguous', fileHash: 'hash-ambiguous', filePath: '/source/amb.epub' }, + ], + }, + ], + }, + matchedBooks: [{ sourceBookId: 's1', targetBookId: 11 }], + }, + plan: { + pathMappings: [{ sourcePrefix: '/source', targetPrefix: '/target' }], + }, + }; + + const targetFilesByBookId = new Map([ + [ + 11, + [ + { id: 101, hash: 'hash-1', absolutePath: '/target/hash.epub' }, + { id: 102, hash: null, absolutePath: '/target/path.epub' }, + { id: 201, hash: 'hash-ambiguous', absolutePath: '/target/other.epub' }, + { id: 202, hash: 'hash-ambiguous', absolutePath: '/target/second.epub' }, + { id: 103, hash: null, absolutePath: '/target/amb.epub' }, + ], + ], + ]); + + const map = buildSourceFileTargetMap(planned as never, targetFilesByBookId); + + expect(map.get('f-hash')).toBe(101); + expect(map.get('f-path')).toBe(102); + expect(map.get('f-ambiguous')).toBe(103); + }); + + it('sanitizes metadata patch values and honors presentFields', () => { + const patch = buildMetadataPatch({ + title: 'A', + subtitle: 'B', + isbn10: '1234567890', + isbn13: '1234567890123', + description: 'desc', + publisher: 'publisher', + publishedYear: 2600, + language: 'english', + pageCount: -1, + seriesName: 'Series', + seriesIndex: 1.4, + rating: 11, + googleBooksId: 'g', + goodreadsId: 'gr', + amazonId: 'amz', + hardcoverId: 'hc', + audibleId: 'aud', + comicvineId: 'cv', + durationSeconds: 3601.7, + abridged: null, + presentFields: ['title', 'subtitle', 'publishedYear', 'pageCount', 'durationSeconds', 'abridged'], + }); + + expect(patch).toEqual({ + title: 'A', + subtitle: 'B', + publishedYear: null, + pageCount: null, + durationSeconds: 3602, + abridged: false, + }); + }); + + it('builds contributor values with truncation and defaulted sort name', () => { + const values = buildContributorValues({ + name: 'Name', + sortName: null, + description: 'Bio', + }); + + expect(values).toEqual({ + name: 'Name', + sortName: 'Name', + description: 'Bio', + }); + }); + + it('derives source contributors from structured data then falls back to parsed legacy names', () => { + const structured = getSourceContributors( + [ + { name: ' B ', sortName: 'Bee', description: null, displayOrder: 2 }, + { name: 'A', sortName: null, description: 'Bio', displayOrder: 1 }, + { name: 'a', sortName: null, description: null, displayOrder: 3 }, + ], + null, + ); + + expect(structured).toEqual([ + { name: 'A', sortName: null, description: 'Bio' }, + { name: 'B', sortName: 'Bee', description: null }, + ]); + + const legacy = getSourceContributors(undefined, '["Ann", "Bob", "ann"]'); + expect(legacy).toEqual([ + { name: 'Ann', sortName: 'Ann', description: null }, + { name: 'Bob', sortName: 'Bob', description: null }, + ]); + }); + + it('prunes undefined keys and recognizes error codes', () => { + expect(pruneUndefined({ a: 1, b: undefined, c: null })).toEqual({ a: 1, c: null }); + expect(hasErrorCode({ code: '23505' }, '23505')).toBe(true); + expect(hasErrorCode({ code: 'x' }, '23505')).toBe(false); + expect(hasErrorCode(null, 'x')).toBe(false); + }); + + it('parses dates and clamps progress percentages', () => { + expect(toDate('2026-01-01T00:00:00.000Z')).toEqual(new Date('2026-01-01T00:00:00.000Z')); + expect(toDate('bad-date')).toBeNull(); + expect(toDate(null)).toBeNull(); + + expect(clampPercent(null)).toBe(0); + expect(clampPercent(-5)).toBe(0); + expect(clampPercent(12.5)).toBe(12.5); + expect(clampPercent(150)).toBe(100); + + expect(clampNonNegative(null)).toBe(0); + expect(clampNonNegative(-2)).toBe(0); + expect(clampNonNegative(9)).toBe(9); + }); + + it('truncates text helpers and normalizes read status from status/percentage', () => { + expect(truncateText('abcdef', 4)).toBe('abcd'); + expect(truncateText('abc', 4)).toBe('abc'); + expect(truncateNullableText(undefined, 2)).toBeUndefined(); + expect(truncateNullableText(null, 2)).toBeNull(); + expect(truncateNullableText('abcd', 3)).toBe('abc'); + + expect(normalizeReadStatus('completed', null)).toBe('read'); + expect(normalizeReadStatus('in_progress', null)).toBe('reading'); + expect(normalizeReadStatus('paused', null)).toBe('on_hold'); + expect(normalizeReadStatus('wishlist', null)).toBe('want_to_read'); + expect(normalizeReadStatus('unknown', 99)).toBe('read'); + expect(normalizeReadStatus('unknown', 10)).toBe('reading'); + expect(normalizeReadStatus('unknown', 0)).toBe('unread'); + }); +}); diff --git a/server/src/modules/migration/executor/migration-executor.service.core.test.ts b/server/src/modules/migration/executor/migration-executor.service.core.test.ts new file mode 100644 index 000000000..89318392e --- /dev/null +++ b/server/src/modules/migration/executor/migration-executor.service.core.test.ts @@ -0,0 +1,310 @@ +import { MigrationExecutorService } from './migration-executor.service'; + +function makeExecutorService() { + const repo = { + findRunById: vi.fn(), + findPlanArtifactById: vi.fn(), + findSourceById: vi.fn(), + hasStageMetrics: vi.fn().mockResolvedValue(false), + clearStageMetrics: vi.fn().mockResolvedValue(undefined), + updateRunState: vi.fn().mockResolvedValue(undefined), + listRunMetrics: vi.fn().mockResolvedValue([]), + }; + const sharedOverlays = { import: vi.fn().mockResolvedValue(undefined) }; + const covers = { import: vi.fn().mockResolvedValue(undefined) }; + const userState = { import: vi.fn().mockResolvedValue(undefined) }; + const config = { get: vi.fn().mockReturnValue('/app-data') }; + const encryption = { decryptConfig: vi.fn((value: unknown) => value) }; + const progressGateway = { emitProgress: vi.fn() }; + + const service = new MigrationExecutorService( + repo as never, + sharedOverlays as never, + covers as never, + userState as never, + config as never, + encryption as never, + progressGateway as never, + ); + + return { service, repo, sharedOverlays, covers, userState, progressGateway }; +} + +describe('MigrationExecutorService core orchestration', () => { + it('start ignores duplicate run ids already in progress', async () => { + const { service } = makeExecutorService(); + const executeSpy = vi.spyOn(service as never, 'execute').mockResolvedValue(undefined); + + service.start(91); + service.start(91); + await Promise.resolve(); + + expect(executeSpy).toHaveBeenCalledTimes(1); + }); + + it('resolveCompletedStages includes only stages that already have persisted metrics', async () => { + const { service, repo } = makeExecutorService(); + repo.hasStageMetrics.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + const completed = await (service as never).resolveCompletedStages(7, 'user_state'); + + expect(completed).toEqual(new Set(['shared_overlays'])); + expect(repo.hasStageMetrics).toHaveBeenCalledWith(7, 'shared_overlays'); + expect(repo.hasStageMetrics).toHaveBeenCalledWith(7, 'book_covers'); + }); + + it('executeStage skips handler when stage is already marked completed', async () => { + const { service, repo } = makeExecutorService(); + const handler = vi.fn(); + + await (service as never).executeStage(22, 'shared_overlays', new Set(['shared_overlays']), handler); + + expect(handler).not.toHaveBeenCalled(); + expect(repo.clearStageMetrics).not.toHaveBeenCalled(); + expect(repo.updateRunState).not.toHaveBeenCalled(); + }); + + it('executeStage clears prior metrics, updates run stage, and executes handler while run is active', async () => { + const { service, repo } = makeExecutorService(); + repo.findRunById.mockResolvedValue({ id: 22, state: 'running' }); + const handler = vi.fn().mockResolvedValue(undefined); + + await (service as never).executeStage(22, 'shared_overlays', new Set(), handler); + + expect(repo.clearStageMetrics).toHaveBeenCalledWith(22, 'shared_overlays'); + expect(repo.updateRunState).toHaveBeenCalledWith(22, 'running', { + currentStage: 'shared_overlays', + errorMessage: null, + }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('failRun marks the run as failed and returns the previous stage', async () => { + const { service, repo } = makeExecutorService(); + repo.findRunById.mockResolvedValue({ id: 5, currentStage: 'book_covers' }); + + const failedStage = await (service as never).failRun(5, 'boom'); + + expect(failedStage).toBe('book_covers'); + expect(repo.updateRunState).toHaveBeenCalledWith( + 5, + 'failed', + expect.objectContaining({ + errorMessage: 'boom', + endedAt: expect.any(Date), + }), + ); + }); + + it('emitRunProgress aggregates stage metrics and emits gateway event', async () => { + const { service, repo, progressGateway } = makeExecutorService(); + repo.listRunMetrics.mockResolvedValue([ + { + stage: 'shared_overlays', + entityType: 'book_metadata', + processed: 4, + imported: 2, + skipped: 1, + unresolved: 1, + failed: 0, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + { + stage: 'book_covers', + entityType: 'book_covers', + processed: 2, + imported: 2, + skipped: 0, + unresolved: 0, + failed: 0, + updatedAt: new Date('2026-01-01T00:00:01.000Z'), + }, + ]); + + await (service as never).emitRunProgress(77, 'running', 'book_covers'); + + expect(progressGateway.emitProgress).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 77, + state: 'running', + currentStage: 'book_covers', + totals: { + processed: 6, + imported: 4, + skipped: 1, + unresolved: 1, + failed: 0, + }, + }), + ); + }); + + it('emitRunProgress swallows progress emission failures', async () => { + const { service, repo, progressGateway } = makeExecutorService(); + repo.listRunMetrics.mockResolvedValue([ + { + stage: 'shared_overlays', + entityType: 'book_metadata', + processed: 1, + imported: 1, + skipped: 0, + unresolved: 0, + failed: 0, + updatedAt: new Date(), + }, + ]); + progressGateway.emitProgress.mockImplementation(() => { + throw new Error('socket down'); + }); + + await expect((service as never).emitRunProgress(1, 'running', 'shared_overlays')).resolves.toBeUndefined(); + }); + + it('execute marks run as failed when source or plan artifact is missing', async () => { + const { service, repo } = makeExecutorService(); + repo.findRunById.mockResolvedValue({ id: 5, state: 'running', sourceId: 1, planArtifactId: 9 }); + repo.findPlanArtifactById.mockResolvedValue(null); + repo.findSourceById.mockResolvedValue({ id: 1, type: 'booklore' }); + + await (service as never).execute(5); + + expect(repo.updateRunState).toHaveBeenCalledWith( + 5, + 'failed', + expect.objectContaining({ + errorMessage: 'Migration run references missing source or plan artifact', + }), + ); + }); + + it('execute marks run as failed when run has no dry-run plan artifact id', async () => { + const { service, repo, sharedOverlays, covers, userState } = makeExecutorService(); + repo.findRunById.mockResolvedValue({ id: 8, state: 'running', sourceId: 1, planArtifactId: null, currentStage: 'init' }); + + await (service as never).execute(8); + + expect(repo.updateRunState).toHaveBeenCalledWith( + 8, + 'failed', + expect.objectContaining({ + errorMessage: 'Migration run is missing a dry-run plan artifact', + }), + ); + expect(sharedOverlays.import).not.toHaveBeenCalled(); + expect(covers.import).not.toHaveBeenCalled(); + expect(userState.import).not.toHaveBeenCalled(); + }); + + it('execute marks run as failed when plan artifact source data is missing', async () => { + const { service, repo } = makeExecutorService(); + repo.findRunById.mockResolvedValue({ id: 9, state: 'running', sourceId: 1, planArtifactId: 10, currentStage: 'init' }); + repo.findPlanArtifactById.mockResolvedValue({ + id: 10, + plan: { matchedBooks: [], unresolvedBooks: [] }, + sourceData: null, + }); + repo.findSourceById.mockResolvedValue({ + id: 1, + type: 'booklore', + connectionConfig: { + host: 'localhost', + user: 'reader', + password: 'secret', + database: 'booklore', + mediaRootPath: '/tmp/booklore-media', + }, + }); + + await (service as never).execute(9); + + expect(repo.updateRunState).toHaveBeenCalledWith( + 9, + 'failed', + expect.objectContaining({ + errorMessage: 'Plan artifact is missing cached source data. Re-run dry-run.', + }), + ); + }); + + it('execute runs stages in order and marks run completed', async () => { + const { service, repo, sharedOverlays, covers, userState, progressGateway } = makeExecutorService(); + repo.findRunById.mockResolvedValue({ id: 11, state: 'running', sourceId: 1, planArtifactId: 44, currentStage: 'init' }); + repo.findPlanArtifactById.mockResolvedValue({ + id: 44, + plan: { matchedBooks: [{ sourceBookId: 'source-1', targetBookId: 901 }], unresolvedBooks: [] }, + sourceData: { books: [] }, + }); + repo.findSourceById.mockResolvedValue({ + id: 1, + type: 'booklore', + connectionConfig: { + host: 'localhost', + user: 'reader', + password: 'secret', + database: 'booklore', + mediaRootPath: '/tmp/booklore-media', + }, + }); + + await (service as never).execute(11); + + expect(sharedOverlays.import).toHaveBeenCalledWith(11, expect.any(Object), expect.any(Function)); + expect(covers.import).toHaveBeenCalledWith(11, expect.any(Object), '/app-data', '/tmp/booklore-media', expect.any(Function)); + expect(userState.import).toHaveBeenCalledWith(11, expect.any(Object), expect.any(Function)); + expect(repo.updateRunState).toHaveBeenCalledWith( + 11, + 'completed', + expect.objectContaining({ + currentStage: 'completed', + errorMessage: null, + }), + ); + + const emittedStates = progressGateway.emitProgress.mock.calls.map((call) => call[0].state); + expect(emittedStates).toEqual(['running', 'running', 'completed']); + }); + + it('execute exits cleanly without failing the run when interrupted', async () => { + const { service, repo, sharedOverlays, progressGateway } = makeExecutorService(); + const running = { id: 12, state: 'running', sourceId: 1, planArtifactId: 77, currentStage: 'init' }; + repo.findRunById.mockResolvedValueOnce(running).mockResolvedValueOnce({ ...running, state: 'cancelled' }); + repo.findPlanArtifactById.mockResolvedValue({ + id: 77, + plan: { matchedBooks: [], unresolvedBooks: [] }, + sourceData: { books: [] }, + }); + repo.findSourceById.mockResolvedValue({ + id: 1, + type: 'booklore', + connectionConfig: { + host: 'localhost', + user: 'reader', + password: 'secret', + database: 'booklore', + mediaRootPath: '/tmp/booklore-media', + }, + }); + + await (service as never).execute(12); + + const terminalStates = repo.updateRunState.mock.calls.map((call) => call[1]); + expect(terminalStates).not.toContain('failed'); + expect(terminalStates).not.toContain('completed'); + expect(sharedOverlays.import).not.toHaveBeenCalled(); + expect(progressGateway.emitProgress).not.toHaveBeenCalled(); + }); + + it('resolveSourceMediaRootPath handles non-booklore and missing media root paths', () => { + const { service } = makeExecutorService(); + + expect((service as never).resolveSourceMediaRootPath('custom', { foo: 'bar' })).toBeNull(); + expect( + (service as never).resolveSourceMediaRootPath('booklore', { + host: 'localhost', + user: 'reader', + password: 'secret', + database: 'booklore', + }), + ).toBeNull(); + }); +}); diff --git a/server/src/modules/migration/executor/migration-executor.service.test.ts b/server/src/modules/migration/executor/migration-executor.service.test.ts index bde289520..25f33372d 100644 --- a/server/src/modules/migration/executor/migration-executor.service.test.ts +++ b/server/src/modules/migration/executor/migration-executor.service.test.ts @@ -426,3 +426,128 @@ describe('UserStateImporter collection import', () => { ); }); }); + +describe('SharedOverlaysImporter domain gating', () => { + it('marks all shared overlay stages skipped when source domains are unavailable', async () => { + const repo = createRepoMock(); + const importRepo = createImportRepoMock(); + const importer = new SharedOverlaysImporter(repo as never, importRepo as never); + + const planned = { + execution: { + matchedBooks: [{ sourceBookId: 'b1', targetBookId: 901 }], + sourceData: { + availableDomains: { + metadata: false, + authors: false, + narrators: false, + genres: false, + tags: false, + }, + books: [], + }, + }, + }; + + await importer.import(120, planned as never, async () => {}); + + expect(repo.setRunMetric).toHaveBeenCalledWith(120, 'shared_overlays', 'book_metadata', expect.objectContaining({ processed: 1, skipped: 1 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(120, 'shared_overlays', 'book_authors', expect.objectContaining({ processed: 1, skipped: 1 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(120, 'shared_overlays', 'book_narrators', expect.objectContaining({ processed: 1, skipped: 1 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(120, 'shared_overlays', 'book_genres', expect.objectContaining({ processed: 1, skipped: 1 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(120, 'shared_overlays', 'book_tags', expect.objectContaining({ processed: 1, skipped: 1 })); + expect(importRepo.batchUpsertBookMetadata).not.toHaveBeenCalled(); + }); +}); + +describe('UserStateImporter progress and bookmark edge cases', () => { + it('marks user-state stages skipped when unavailable', async () => { + const repo = createRepoMock(); + const importRepo = createImportRepoMock(); + const importer = new UserStateImporter(repo as never, importRepo as never); + + const planned = { + plan: { + userMappings: [], + pathMappings: [], + }, + execution: { + matchedBooks: [], + sourceData: { + availableDomains: { + userBookStatuses: false, + readingProgress: false, + bookmarks: false, + annotations: false, + shelves: false, + }, + books: [], + userBookStatuses: [], + userFileProgress: [], + bookmarks: [], + annotations: [], + shelves: [], + shelfBooks: [], + }, + }, + }; + + await importer.import(121, planned as never, async () => {}); + + expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'user_book_status', expect.objectContaining({ processed: 0, skipped: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'reading_progress', expect.objectContaining({ processed: 0, skipped: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'audiobook_progress', expect.objectContaining({ processed: 0, skipped: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'bookmarks', expect.objectContaining({ processed: 0, skipped: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'annotations', expect.objectContaining({ processed: 0, skipped: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(121, 'user_state', 'collections', expect.objectContaining({ processed: 0, skipped: 0 })); + }); + + it('derives bookmark position from track index when source file id is missing', async () => { + const repo = createRepoMock(); + const importRepo = createImportRepoMock(); + const importer = new UserStateImporter(repo as never, importRepo as never); + + const planned = { + execution: { + sourceData: { + availableDomains: { + bookmarks: true, + }, + books: [ + { + sourceBookId: 'source-1', + files: [ + { sourceFileId: 'f1', durationSeconds: 30 }, + { sourceFileId: 'f2', durationSeconds: 45 }, + ], + }, + ], + bookmarks: [ + { + sourceUserId: 'u1', + sourceBookId: 'source-1', + sourceFileId: null, + title: 'Chapter mark', + cfi: '/6/2', + positionSeconds: 15, + trackIndex: 2, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }, + }, + }; + + await (importer as any).importBookmarks(122, planned, new Map([['u1', 10]]), new Map([['source-1', 200]]), async () => {}); + + const inserted = (importRepo.batchInsertBookmarks.mock.calls as unknown[][])[0][0] as Array>; + expect(inserted[0]).toEqual( + expect.objectContaining({ + userId: 10, + bookId: 200, + positionSeconds: 45, + }), + ); + expect(repo.setRunMetric).toHaveBeenCalledWith(122, 'user_state', 'bookmarks', expect.objectContaining({ processed: 1, imported: 1 })); + }); +}); diff --git a/server/src/modules/migration/executor/migration-import.repository.test.ts b/server/src/modules/migration/executor/migration-import.repository.test.ts new file mode 100644 index 000000000..c338bdd68 --- /dev/null +++ b/server/src/modules/migration/executor/migration-import.repository.test.ts @@ -0,0 +1,432 @@ +import * as schema from '../../../db/schema'; +import { MigrationImportRepository } from './migration-import.repository'; + +describe('MigrationImportRepository', () => { + it('uses transactional repository instance inside withTransaction', async () => { + const returning = vi.fn().mockResolvedValue([{ id: 44 }]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning }); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + + const tx = { insert }; + const db = { + transaction: vi.fn().mockImplementation(async (callback: (value: typeof tx) => Promise) => callback(tx)), + }; + + const repo = new MigrationImportRepository(db as never); + const row = await repo.withTransaction((transactionRepo) => transactionRepo.upsertTag('Fantasy')); + + expect(row).toEqual({ id: 44 }); + expect(insert).toHaveBeenCalledWith(schema.tags); + }); + + it('upserts book metadata in batches and updates provided scalar id fields on conflict', async () => { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + + const repo = new MigrationImportRepository({ insert } as never); + await repo.batchUpsertBookMetadata([ + { + bookId: 1, + title: 'Dune', + openLibraryId: 'OL1W', + itunesId: '123', + }, + { + bookId: 2, + publisher: 'Ace', + }, + ]); + + expect(insert).toHaveBeenCalledTimes(2); + expect(onConflictDoUpdate).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + target: schema.bookMetadata.bookId, + set: expect.objectContaining({ + title: expect.anything(), + openLibraryId: expect.anything(), + itunesId: expect.anything(), + updatedAt: expect.anything(), + }), + }), + ); + }); + + it('clearUserBookStatuses no-ops for empty targets and deduplicates ids otherwise', async () => { + const where = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where }); + const repo = new MigrationImportRepository({ delete: deleteFn } as never); + + await repo.clearUserBookStatuses([], [1, 2]); + await repo.clearUserBookStatuses([1], []); + expect(deleteFn).not.toHaveBeenCalled(); + + await repo.clearUserBookStatuses([1, 1, 2], [10, 10, 11]); + expect(deleteFn).toHaveBeenCalledTimes(1); + expect(where).toHaveBeenCalledTimes(1); + }); + + it('batchUpsertAuthors returns name-to-id mapping from inserted rows', async () => { + const returning = vi.fn().mockResolvedValue([ + { id: 10, name: 'Frank Herbert' }, + { id: 11, name: 'Brian Herbert' }, + ]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning }); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + + const repo = new MigrationImportRepository({ insert } as never); + const map = await repo.batchUpsertAuthors([ + { name: 'Frank Herbert', sortName: 'Herbert, Frank' }, + { name: 'Brian Herbert', sortName: 'Herbert, Brian' }, + ]); + + expect(map).toEqual( + new Map([ + ['Frank Herbert', 10], + ['Brian Herbert', 11], + ]), + ); + }); + + it('fetches primary file maps and marks only audio formats as audiobook primary', async () => { + const where = vi.fn().mockResolvedValue([ + { bookId: 1, primaryFileId: 101, primaryFileFormat: 'epub' }, + { bookId: 2, primaryFileId: 202, primaryFileFormat: 'mp3' }, + { bookId: 3, primaryFileId: null, primaryFileFormat: null }, + ]); + const leftJoin = vi.fn().mockReturnValue({ where }); + const from = vi.fn().mockReturnValue({ leftJoin }); + const select = vi.fn().mockReturnValue({ from }); + + const repo = new MigrationImportRepository({ select } as never); + await expect(repo.fetchTargetBookPrimaryFiles([])).resolves.toEqual({ + primaryFilesByBookId: new Map(), + audiobookPrimaryFilesByBookId: new Map(), + }); + const result = await repo.fetchTargetBookPrimaryFiles([1, 2, 3]); + + expect(result.primaryFilesByBookId).toEqual( + new Map([ + [1, 101], + [2, 202], + ]), + ); + expect(result.audiobookPrimaryFilesByBookId).toEqual(new Map([[2, 202]])); + }); + + it('groups target files by book id and supports empty input short-circuit', async () => { + const where = vi.fn().mockResolvedValue([ + { id: 11, bookId: 1, hash: 'h1', absolutePath: '/books/1.epub' }, + { id: 12, bookId: 1, hash: null, absolutePath: '/books/1.m4b' }, + { id: 21, bookId: 2, hash: 'h2', absolutePath: '/books/2.epub' }, + ]); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + + const repo = new MigrationImportRepository({ select } as never); + + await expect(repo.fetchTargetBookFiles([])).resolves.toEqual(new Map()); + await expect(repo.fetchTargetBookFiles([1, 2])).resolves.toEqual( + new Map([ + [ + 1, + [ + { id: 11, hash: 'h1', absolutePath: '/books/1.epub' }, + { id: 12, hash: null, absolutePath: '/books/1.m4b' }, + ], + ], + [[2, [{ id: 21, hash: 'h2', absolutePath: '/books/2.epub' }]]][0], + ]), + ); + }); + + it('returns library-id map for book ids and skips query for empty input', async () => { + const where = vi.fn().mockResolvedValue([ + { id: 1, libraryId: 10 }, + { id: 2, libraryId: 20 }, + ]); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + + const repo = new MigrationImportRepository({ select } as never); + + await expect(repo.fetchLibraryIdsByBookIds([])).resolves.toEqual(new Map()); + await expect(repo.fetchLibraryIdsByBookIds([1, 2])).resolves.toEqual( + new Map([ + [1, 10], + [2, 20], + ]), + ); + }); + + it('upsertCollectionBook reflects whether insert produced a row', async () => { + const returning = vi + .fn() + .mockResolvedValueOnce([{ collectionId: 1 }]) + .mockResolvedValueOnce([]); + const onConflictDoNothing = vi.fn().mockReturnValue({ returning }); + const values = vi.fn().mockReturnValue({ onConflictDoNothing }); + const insert = vi.fn().mockReturnValue({ values }); + + const repo = new MigrationImportRepository({ insert } as never); + + await expect(repo.upsertCollectionBook(1, 10)).resolves.toBe(true); + await expect(repo.upsertCollectionBook(1, 10)).resolves.toBe(false); + }); + + it('executes relation delete/insert wrapper methods against expected tables', async () => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where: deleteWhere }); + + const onConflictDoNothing = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoNothing }); + const insert = vi.fn().mockReturnValue({ values }); + + const repo = new MigrationImportRepository({ delete: deleteFn, insert } as never); + + await repo.deleteBookAuthors(1); + await repo.deleteBookNarrators(1); + await repo.deleteBookGenres(1); + await repo.deleteBookTags(1); + await repo.clearCollectionBooks(10); + await repo.insertBookAuthor(1, 2, 0); + await repo.insertBookNarrator(1, 3, 1); + await repo.insertBookGenre(1, 4); + await repo.insertBookTag(1, 5); + await repo.insertBookmark({ userId: 1, bookId: 1, cfi: '/6/2', title: 'x', createdAt: new Date() } as never); + + expect(deleteFn).toHaveBeenCalledWith(schema.bookAuthors); + expect(deleteFn).toHaveBeenCalledWith(schema.bookNarrators); + expect(deleteFn).toHaveBeenCalledWith(schema.bookGenres); + expect(deleteFn).toHaveBeenCalledWith(schema.bookTags); + expect(deleteFn).toHaveBeenCalledWith(schema.collectionBooks); + expect(insert).toHaveBeenCalledWith(schema.bookAuthors); + expect(insert).toHaveBeenCalledWith(schema.bookNarrators); + expect(insert).toHaveBeenCalledWith(schema.bookGenres); + expect(insert).toHaveBeenCalledWith(schema.bookTags); + expect(insert).toHaveBeenCalledWith(schema.bookmarks); + }); + + it('marks cover as custom with aligned insert/update timestamps', async () => { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + const repo = new MigrationImportRepository({ insert } as never); + + await repo.markCoverAsCustom(77); + + const insertedPayload = values.mock.calls[0][0] as { coverSource: string; updatedAt: Date }; + const conflictPayload = onConflictDoUpdate.mock.calls[0][0] as { set: { coverSource: string; updatedAt: Date } }; + + expect(insertedPayload.coverSource).toBe('custom'); + expect(insertedPayload.updatedAt).toBeInstanceOf(Date); + expect(conflictPayload.set.coverSource).toBe('custom'); + expect(conflictPayload.set.updatedAt).toEqual(insertedPayload.updatedAt); + }); + + it('upsert progress methods omit conflict target keys from update set', async () => { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + const repo = new MigrationImportRepository({ insert } as never); + + await repo.upsertUserBookStatus({ + userId: 1, + bookId: 2, + status: 'read', + source: 'manual', + startedAt: null, + finishedAt: null, + updatedAt: new Date(), + } as never); + await repo.upsertReadingProgress({ + bookFileId: 10, + userId: 1, + percentage: 50, + cfi: null, + pageNumber: null, + positionSeconds: null, + updatedAt: new Date(), + } as never); + await repo.upsertAudiobookProgress({ + userId: 1, + bookId: 2, + percentage: 75, + currentFileId: 10, + positionSeconds: 120, + updatedAt: new Date(), + } as never); + + const setUserBookStatus = onConflictDoUpdate.mock.calls[0][0].set as Record; + expect(setUserBookStatus).not.toHaveProperty('userId'); + expect(setUserBookStatus).not.toHaveProperty('bookId'); + + const setReadingProgress = onConflictDoUpdate.mock.calls[1][0].set as Record; + expect(setReadingProgress).not.toHaveProperty('bookFileId'); + expect(setReadingProgress).not.toHaveProperty('userId'); + + const setAudiobookProgress = onConflictDoUpdate.mock.calls[2][0].set as Record; + expect(setAudiobookProgress).not.toHaveProperty('userId'); + expect(setAudiobookProgress).not.toHaveProperty('bookId'); + }); + + it('clear reading/bookmark/annotation methods short-circuit empty targets', async () => { + const where = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where }); + const repo = new MigrationImportRepository({ delete: deleteFn } as never); + + await repo.clearReadingProgress([], [1]); + await repo.clearReadingProgress([1], []); + await repo.clearBookmarks([], [1]); + await repo.clearBookmarks([1], []); + await repo.clearAnnotations([], [1]); + await repo.clearAnnotations([1], []); + + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('fetchExistingCollections short-circuits empty user lists', async () => { + const select = vi.fn(); + const repo = new MigrationImportRepository({ select } as never); + + await expect(repo.fetchExistingCollections([])).resolves.toEqual([]); + expect(select).not.toHaveBeenCalled(); + }); + + it('executes single-row upsert wrappers and collection helpers with non-empty inputs', async () => { + const returning = vi + .fn() + .mockResolvedValueOnce([{ id: 101 }]) + .mockResolvedValueOnce([{ id: 102 }]) + .mockResolvedValueOnce([{ id: 103 }]) + .mockResolvedValueOnce([{ id: 104 }]) + .mockResolvedValueOnce([{ id: 500 }]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning }); + const onConflictDoNothing = vi.fn().mockReturnValue({ returning }); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate, onConflictDoNothing, returning }); + const insert = vi.fn().mockReturnValue({ values }); + + const where = vi.fn().mockResolvedValue([{ id: 7, userId: 10, name: 'Sci-fi' }]); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + + const repo = new MigrationImportRepository({ insert, select } as never); + + await repo.upsertBookMetadata(1, { title: 'Dune' }); + await expect(repo.upsertAuthor({ name: 'Frank Herbert', sortName: 'Herbert, Frank' } as never)).resolves.toEqual({ id: 101 }); + await expect(repo.upsertNarrator({ name: 'Narrator', sortName: 'Narrator' })).resolves.toEqual({ id: 102 }); + await expect(repo.upsertGenre('Science Fiction')).resolves.toEqual({ id: 103 }); + await expect(repo.upsertTag('Space Opera')).resolves.toEqual({ id: 104 }); + await repo.insertAnnotation({ userId: 1, bookId: 1, cfi: '/6/2', text: 'note', color: 'yellow', style: 'highlight' } as never); + await expect(repo.fetchExistingCollections([10])).resolves.toEqual([{ id: 7, userId: 10, name: 'Sci-fi' }]); + await expect( + repo.insertCollection({ userId: 10, name: 'Imported', description: null, syncToKobo: false, displayOrder: 0 } as never), + ).resolves.toEqual({ id: 500 }); + + expect(insert).toHaveBeenCalledWith(schema.bookMetadata); + expect(insert).toHaveBeenCalledWith(schema.authors); + expect(insert).toHaveBeenCalledWith(schema.narrators); + expect(insert).toHaveBeenCalledWith(schema.genres); + expect(insert).toHaveBeenCalledWith(schema.tags); + expect(insert).toHaveBeenCalledWith(schema.annotations); + expect(insert).toHaveBeenCalledWith(schema.collections); + expect(select).toHaveBeenCalled(); + }); + + it('executes non-empty clear methods for progress, bookmarks, annotations, and audiobooks', async () => { + const where = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where }); + const repo = new MigrationImportRepository({ delete: deleteFn } as never); + + await repo.clearReadingProgress([1, 1], [10, 10]); + await repo.clearAudiobookProgress([1, 1], [2, 2]); + await repo.clearBookmarks([1, 1], [2, 2]); + await repo.clearAnnotations([1, 1], [2, 2]); + + expect(deleteFn).toHaveBeenCalledWith(schema.readingProgress); + expect(deleteFn).toHaveBeenCalledWith(schema.audiobookProgress); + expect(deleteFn).toHaveBeenCalledWith(schema.bookmarks); + expect(deleteFn).toHaveBeenCalledWith(schema.annotations); + expect(where).toHaveBeenCalledTimes(4); + }); + + it('runs batch contributor and taxonomy helpers for non-empty and empty arrays', async () => { + const returning = vi + .fn() + .mockResolvedValueOnce([{ id: 201, name: 'Narrator A' }]) + .mockResolvedValueOnce([{ id: 301, name: 'Fantasy' }]) + .mockResolvedValueOnce([{ id: 401, name: 'Epic' }]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning }); + const onConflictDoNothing = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate, onConflictDoNothing }); + const insert = vi.fn().mockReturnValue({ values }); + const where = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where }); + + const repo = new MigrationImportRepository({ insert, delete: deleteFn } as never); + + await repo.batchUpsertBookMetadata([]); + await repo.batchDeleteBookAuthors([]); + await repo.batchDeleteBookNarrators([]); + await repo.batchDeleteBookGenres([]); + await repo.batchDeleteBookTags([]); + expect(insert).not.toHaveBeenCalled(); + expect(deleteFn).not.toHaveBeenCalled(); + + await repo.batchDeleteBookAuthors([1]); + await repo.batchInsertBookAuthors([{ bookId: 1, authorId: 2, displayOrder: 0 }]); + await repo.batchDeleteBookNarrators([1]); + await expect(repo.batchUpsertNarrators([{ name: 'Narrator A', sortName: 'Narrator A' }])).resolves.toEqual(new Map([['Narrator A', 201]])); + await repo.batchInsertBookNarrators([{ bookId: 1, narratorId: 201, displayOrder: 0 }]); + await repo.batchDeleteBookGenres([1]); + await expect(repo.batchUpsertGenres(['Fantasy'])).resolves.toEqual(new Map([['Fantasy', 301]])); + await repo.batchInsertBookGenres([{ bookId: 1, genreId: 301 }]); + await repo.batchDeleteBookTags([1]); + await expect(repo.batchUpsertTags(['Epic'])).resolves.toEqual(new Map([['Epic', 401]])); + await repo.batchInsertBookTags([{ bookId: 1, tagId: 401 }]); + + expect(deleteFn).toHaveBeenCalledWith(schema.bookAuthors); + expect(deleteFn).toHaveBeenCalledWith(schema.bookNarrators); + expect(deleteFn).toHaveBeenCalledWith(schema.bookGenres); + expect(deleteFn).toHaveBeenCalledWith(schema.bookTags); + expect(insert).toHaveBeenCalledWith(schema.bookAuthors); + expect(insert).toHaveBeenCalledWith(schema.bookNarrators); + expect(insert).toHaveBeenCalledWith(schema.genres); + expect(insert).toHaveBeenCalledWith(schema.tags); + }); + + it('runs batch user-state write methods for non-empty and empty batches', async () => { + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const onConflictDoNothing = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate, onConflictDoNothing }); + const insert = vi.fn().mockReturnValue({ values }); + const repo = new MigrationImportRepository({ insert } as never); + + await repo.batchUpsertUserBookStatuses([]); + await repo.batchUpsertReadingProgress([]); + await repo.batchUpsertAudiobookProgress([]); + await repo.batchInsertBookmarks([]); + await repo.batchInsertAnnotations([]); + await repo.batchInsertCollectionBooks([]); + expect(insert).not.toHaveBeenCalled(); + + await repo.batchUpsertUserBookStatuses([{ userId: 1, bookId: 2, status: 'read', source: 'manual', updatedAt: new Date() } as never]); + await repo.batchUpsertReadingProgress([{ userId: 1, bookFileId: 10, percentage: 50, updatedAt: new Date() } as never]); + await repo.batchUpsertAudiobookProgress([ + { userId: 1, bookId: 2, percentage: 25, currentFileId: 10, positionSeconds: 0, updatedAt: new Date() } as never, + ]); + await repo.batchInsertBookmarks([{ userId: 1, bookId: 2, title: 'mark', cfi: '/6/2', createdAt: new Date() } as never]); + await repo.batchInsertAnnotations([{ userId: 1, bookId: 2, cfi: '/6/2', text: 'a', color: 'yellow', style: 'highlight' } as never]); + await repo.batchInsertCollectionBooks([{ collectionId: 7, bookId: 2 }]); + + expect(insert).toHaveBeenCalledWith(schema.userBookStatus); + expect(insert).toHaveBeenCalledWith(schema.readingProgress); + expect(insert).toHaveBeenCalledWith(schema.audiobookProgress); + expect(insert).toHaveBeenCalledWith(schema.bookmarks); + expect(insert).toHaveBeenCalledWith(schema.annotations); + expect(insert).toHaveBeenCalledWith(schema.collectionBooks); + }); +}); diff --git a/server/src/modules/migration/executor/migration-import.repository.ts b/server/src/modules/migration/executor/migration-import.repository.ts index 550b8956a..ecdac6b6c 100644 --- a/server/src/modules/migration/executor/migration-import.repository.ts +++ b/server/src/modules/migration/executor/migration-import.repository.ts @@ -48,6 +48,8 @@ const BOOK_METADATA_COLUMN_BY_PROPERTY = { hardcoverId: 'hardcover_id', audibleId: 'audible_id', comicvineId: 'comicvine_id', + openLibraryId: 'open_library_id', + itunesId: 'itunes_id', durationSeconds: 'duration_seconds', abridged: 'abridged', } as const; diff --git a/server/src/modules/migration/executor/shared-overlays.importer.test.ts b/server/src/modules/migration/executor/shared-overlays.importer.test.ts new file mode 100644 index 000000000..04eab9c67 --- /dev/null +++ b/server/src/modules/migration/executor/shared-overlays.importer.test.ts @@ -0,0 +1,113 @@ +import { SharedOverlaysImporter } from './shared-overlays.importer'; + +function makeRepoMocks() { + const repo = { + setRunMetric: vi.fn().mockResolvedValue(undefined), + }; + const importRepo = { + withTransaction: vi.fn().mockImplementation(async (handler: (repo: typeof importRepo) => Promise) => handler(importRepo)), + batchUpsertBookMetadata: vi.fn().mockResolvedValue(undefined), + batchDeleteBookAuthors: vi.fn().mockResolvedValue(undefined), + batchUpsertAuthors: vi.fn().mockResolvedValue(new Map()), + batchInsertBookAuthors: vi.fn().mockResolvedValue(undefined), + batchDeleteBookNarrators: vi.fn().mockResolvedValue(undefined), + batchUpsertNarrators: vi.fn().mockResolvedValue(new Map()), + batchInsertBookNarrators: vi.fn().mockResolvedValue(undefined), + batchDeleteBookGenres: vi.fn().mockResolvedValue(undefined), + batchUpsertGenres: vi.fn().mockResolvedValue(new Map()), + batchInsertBookGenres: vi.fn().mockResolvedValue(undefined), + batchDeleteBookTags: vi.fn().mockResolvedValue(undefined), + batchUpsertTags: vi.fn().mockResolvedValue(new Map()), + batchInsertBookTags: vi.fn().mockResolvedValue(undefined), + }; + + return { repo, importRepo }; +} + +describe('SharedOverlaysImporter', () => { + it('imports metadata/contributors/genres/tags and tracks unresolved source books', async () => { + const { repo, importRepo } = makeRepoMocks(); + importRepo.batchUpsertAuthors.mockResolvedValue( + new Map([ + ['Frank Herbert', 1], + ['Brian Herbert', 2], + ]), + ); + importRepo.batchUpsertNarrators.mockResolvedValue(new Map([['Narrator A', 7]])); + importRepo.batchUpsertGenres.mockResolvedValue(new Map([['Science Fiction', 11]])); + importRepo.batchUpsertTags.mockResolvedValue(new Map([['Space Opera', 12]])); + + const importer = new SharedOverlaysImporter(repo as never, importRepo as never); + + const planned = { + execution: { + matchedBooks: [ + { sourceBookId: 'source-1', targetBookId: 901 }, + { sourceBookId: 'missing', targetBookId: 902 }, + ], + sourceData: { + availableDomains: { + metadata: true, + authors: true, + narrators: true, + genres: true, + tags: true, + }, + books: [ + { + sourceBookId: 'source-1', + title: 'Dune', + subtitle: null, + isbn10: null, + isbn13: null, + description: 'Classic', + publisher: 'Ace', + publishedYear: 1965, + language: 'en', + author: 'unused', + authors: [ + { name: 'Frank Herbert', sortName: 'Herbert, Frank', displayOrder: 0, description: null }, + { name: 'Brian Herbert', sortName: 'Herbert, Brian', displayOrder: 1, description: null }, + ], + narrators: [{ name: 'Narrator A', sortName: null, displayOrder: 0, description: null }], + genres: ['Science Fiction'], + tags: ['Space Opera'], + }, + ], + }, + }, + }; + + await importer.import(201, planned as never, async () => {}); + + expect(importRepo.batchUpsertBookMetadata).toHaveBeenCalledWith([ + expect.objectContaining({ + bookId: 901, + title: 'Dune', + description: 'Classic', + publisher: 'Ace', + publishedYear: 1965, + language: 'en', + }), + ]); + expect(importRepo.batchDeleteBookAuthors).toHaveBeenCalledWith([901]); + expect(importRepo.batchInsertBookAuthors).toHaveBeenCalledWith([ + { bookId: 901, authorId: 1, displayOrder: 0 }, + { bookId: 901, authorId: 2, displayOrder: 1 }, + ]); + expect(importRepo.batchInsertBookNarrators).toHaveBeenCalledWith([{ bookId: 901, narratorId: 7, displayOrder: 0 }]); + expect(importRepo.batchInsertBookGenres).toHaveBeenCalledWith([{ bookId: 901, genreId: 11 }]); + expect(importRepo.batchInsertBookTags).toHaveBeenCalledWith([{ bookId: 901, tagId: 12 }]); + + expect(repo.setRunMetric).toHaveBeenCalledWith( + 201, + 'shared_overlays', + 'book_metadata', + expect.objectContaining({ + processed: 2, + imported: 1, + unresolved: 1, + }), + ); + }); +}); diff --git a/server/src/modules/migration/executor/user-state.importer.test.ts b/server/src/modules/migration/executor/user-state.importer.test.ts new file mode 100644 index 000000000..1782ca4cb --- /dev/null +++ b/server/src/modules/migration/executor/user-state.importer.test.ts @@ -0,0 +1,407 @@ +import { UserStateImporter } from './user-state.importer'; + +function makeImporter() { + const repo = { + setRunMetric: vi.fn().mockResolvedValue(undefined), + }; + const importRepo = { + withTransaction: vi.fn().mockImplementation(async (handler: (repo: typeof importRepo) => Promise) => handler(importRepo)), + fetchTargetBookPrimaryFiles: vi.fn().mockResolvedValue({ + primaryFilesByBookId: new Map([[200, 500]]), + audiobookPrimaryFilesByBookId: new Map([[200, 500]]), + }), + fetchTargetBookFiles: vi.fn().mockResolvedValue(new Map()), + clearUserBookStatuses: vi.fn().mockResolvedValue(undefined), + batchUpsertUserBookStatuses: vi.fn().mockResolvedValue(undefined), + clearReadingProgress: vi.fn().mockResolvedValue(undefined), + batchUpsertReadingProgress: vi.fn().mockResolvedValue(undefined), + clearAudiobookProgress: vi.fn().mockResolvedValue(undefined), + batchUpsertAudiobookProgress: vi.fn().mockResolvedValue(undefined), + clearBookmarks: vi.fn().mockResolvedValue(undefined), + batchInsertBookmarks: vi.fn().mockResolvedValue(undefined), + clearAnnotations: vi.fn().mockResolvedValue(undefined), + batchInsertAnnotations: vi.fn().mockResolvedValue(undefined), + fetchExistingCollections: vi.fn().mockResolvedValue([]), + insertCollection: vi.fn().mockResolvedValue({ id: 700 }), + clearCollectionBooks: vi.fn().mockResolvedValue(undefined), + batchInsertCollectionBooks: vi.fn().mockResolvedValue(undefined), + }; + + const importer = new UserStateImporter(repo as never, importRepo as never); + return { importer, repo, importRepo }; +} + +describe('UserStateImporter', () => { + it('imports user status/progress/bookmarks/annotations/collections across all domains', async () => { + const { importer, repo, importRepo } = makeImporter(); + + const planned = { + plan: { + userMappings: [{ sourceUserId: 'u1', targetUserId: 10 }], + pathMappings: [], + }, + execution: { + matchedBooks: [{ sourceBookId: 'b-source', targetBookId: 200 }], + sourceData: { + availableDomains: { + userBookStatuses: true, + readingProgress: true, + bookmarks: true, + annotations: true, + shelves: true, + }, + books: [ + { + sourceBookId: 'b-source', + files: [ + { sourceFileId: 'f1', durationSeconds: 30 }, + { sourceFileId: 'f2', durationSeconds: 45 }, + ], + }, + ], + userBookStatuses: [ + { + sourceUserId: 'u1', + sourceBookId: 'b-source', + status: 'completed', + percentage: 100, + startedAt: '2026-01-01T00:00:00.000Z', + finishedAt: null, + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ], + userFileProgress: [ + { + sourceUserId: 'u1', + sourceBookId: 'b-source', + sourceFileId: null, + percentage: 120, + cfi: '/6/2', + pageNumber: 12, + positionSeconds: 30, + updatedAt: '2026-01-03T00:00:00.000Z', + }, + ], + bookmarks: [ + { + sourceUserId: 'u1', + sourceBookId: 'b-source', + sourceFileId: null, + title: 'Mark', + cfi: '/6/2', + positionSeconds: 15, + trackIndex: 2, + createdAt: '2026-01-04T00:00:00.000Z', + }, + ], + annotations: [ + { + sourceUserId: 'u1', + sourceBookId: 'b-source', + cfi: '/6/2', + text: 'Highlight', + color: null, + style: null, + note: 'Note', + chapterTitle: 'Ch 1', + createdAt: '2026-01-05T00:00:00.000Z', + updatedAt: '2026-01-06T00:00:00.000Z', + }, + ], + shelves: [{ sourceShelfId: 's1', sourceUserId: 'u1', name: 'Favorites' }], + shelfBooks: [{ sourceShelfId: 's1', sourceUserId: 'u1', sourceBookId: 'b-source' }], + }, + }, + }; + + const ensureRunning = vi.fn().mockResolvedValue(undefined); + await importer.import(300, planned as never, ensureRunning); + + expect(importRepo.batchUpsertUserBookStatuses).toHaveBeenCalledWith([ + expect.objectContaining({ + userId: 10, + bookId: 200, + status: 'read', + source: 'manual', + }), + ]); + expect(importRepo.batchUpsertReadingProgress).toHaveBeenCalledWith([ + expect.objectContaining({ + bookFileId: 500, + userId: 10, + percentage: 100, + }), + ]); + expect(importRepo.batchUpsertAudiobookProgress).toHaveBeenCalledWith([ + expect.objectContaining({ + userId: 10, + bookId: 200, + currentFileId: 500, + positionSeconds: 30, + }), + ]); + expect(importRepo.batchInsertBookmarks).toHaveBeenCalledWith([ + expect.objectContaining({ + userId: 10, + bookId: 200, + positionSeconds: 45, + }), + ]); + expect(importRepo.batchInsertAnnotations).toHaveBeenCalledWith([ + expect.objectContaining({ + userId: 10, + bookId: 200, + color: 'yellow', + style: 'highlight', + }), + ]); + expect(importRepo.batchInsertCollectionBooks).toHaveBeenCalledWith([{ collectionId: 700, bookId: 200 }]); + expect(repo.setRunMetric).toHaveBeenCalledWith( + 300, + 'user_state', + 'collections', + expect.objectContaining({ + processed: 1, + imported: 1, + }), + ); + expect(ensureRunning).toHaveBeenCalled(); + }); + + it('tracks unresolved rows across domains and keeps bookmark position when track index is invalid', async () => { + const { importer, repo, importRepo } = makeImporter(); + importRepo.fetchTargetBookPrimaryFiles.mockResolvedValue({ + primaryFilesByBookId: new Map([[200, 500]]), + audiobookPrimaryFilesByBookId: new Map([[200, 500]]), + }); + importRepo.fetchExistingCollections.mockResolvedValue([ + { id: 88, userId: 10, name: 'Existing Imported', description: 'Imported from Booklore migration shelf: s-existing' }, + { id: 89, userId: 10, name: 'Not Imported', description: 'Imported from Booklore migration shelf: ' }, + { id: 90, userId: 10, name: 'Favorites', description: null }, + { id: 91, userId: 10, name: 'Favorites (Booklore)', description: null }, + ]); + importRepo.insertCollection.mockResolvedValue({ id: 701 }); + + const planned = { + plan: { + userMappings: [{ sourceUserId: 'u1', targetUserId: 10 }], + pathMappings: [], + }, + execution: { + matchedBooks: [ + { sourceBookId: 'b-source', targetBookId: 200 }, + { sourceBookId: 'b-missing-file', targetBookId: 201 }, + ], + sourceData: { + availableDomains: { + userBookStatuses: true, + readingProgress: true, + bookmarks: true, + annotations: true, + shelves: true, + }, + books: [ + { + sourceBookId: 'b-source', + files: [ + { sourceFileId: 'f1', durationSeconds: 30 }, + { sourceFileId: 'f2', durationSeconds: 45 }, + ], + }, + ], + userBookStatuses: [ + { + sourceUserId: 'missing-user', + sourceBookId: 'b-source', + status: 'reading', + percentage: 20, + startedAt: null, + finishedAt: null, + updatedAt: null, + }, + ], + userFileProgress: [ + { + sourceUserId: 'u1', + sourceBookId: 'b-missing-file', + sourceFileId: null, + percentage: 10, + cfi: null, + pageNumber: null, + positionSeconds: 5, + updatedAt: null, + }, + { + sourceUserId: 'missing-user', + sourceBookId: 'b-source', + sourceFileId: null, + percentage: 10, + cfi: null, + pageNumber: null, + positionSeconds: 5, + updatedAt: null, + }, + ], + bookmarks: [ + { + sourceUserId: 'u1', + sourceBookId: 'b-source', + sourceFileId: null, + title: 'Invalid Track', + cfi: '/6/2', + positionSeconds: 12, + trackIndex: 99, + createdAt: null, + }, + { + sourceUserId: 'missing-user', + sourceBookId: 'b-source', + sourceFileId: null, + title: 'Unmapped', + cfi: '/6/4', + positionSeconds: 5, + trackIndex: 0, + createdAt: null, + }, + ], + annotations: [ + { + sourceUserId: 'missing-user', + sourceBookId: 'b-source', + cfi: '/6/2', + text: 'X', + color: null, + style: null, + note: null, + chapterTitle: null, + }, + ], + shelves: [ + { sourceShelfId: 's-existing', sourceUserId: 'u1', name: 'Existing Imported' }, + { sourceShelfId: 's-new', sourceUserId: 'u1', name: 'Favorites' }, + { sourceShelfId: 's-no-user', sourceUserId: 'missing-user', name: 'Private' }, + ], + shelfBooks: [ + { sourceShelfId: 'missing-shelf', sourceUserId: 'u1', sourceBookId: 'b-source' }, + { sourceShelfId: 's-new', sourceUserId: 'other-user', sourceBookId: 'b-source' }, + { sourceShelfId: 's-no-user', sourceUserId: 'missing-user', sourceBookId: 'b-source' }, + { sourceShelfId: 's-new', sourceUserId: 'u1', sourceBookId: 'b-source' }, + { sourceShelfId: 's-new', sourceUserId: 'u1', sourceBookId: 'missing-book' }, + ], + }, + }, + }; + + await importer.import(301, planned as never, vi.fn().mockResolvedValue(undefined)); + + expect(importRepo.batchInsertBookmarks).toHaveBeenCalledWith([ + expect.objectContaining({ + userId: 10, + bookId: 200, + positionSeconds: 12, + }), + ]); + expect(importRepo.insertCollection).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Favorites (Booklore 2)', + }), + ); + + expect(repo.setRunMetric).toHaveBeenCalledWith(301, 'user_state', 'user_book_status', expect.objectContaining({ unresolved: 1 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(301, 'user_state', 'reading_progress', expect.objectContaining({ unresolved: 2 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(301, 'user_state', 'collections', expect.objectContaining({ unresolved: 4 })); + }); + + it('skips all user-state domains when source adapter marks them unavailable', async () => { + const { importer, repo, importRepo } = makeImporter(); + const planned = { + plan: { + userMappings: [{ sourceUserId: 'u1', targetUserId: 10 }], + pathMappings: [], + }, + execution: { + matchedBooks: [{ sourceBookId: 'b-source', targetBookId: 200 }], + sourceData: { + availableDomains: { + userBookStatuses: false, + readingProgress: false, + bookmarks: false, + annotations: false, + shelves: false, + }, + books: [], + userBookStatuses: [], + userFileProgress: [], + bookmarks: [], + annotations: [], + shelves: [], + shelfBooks: [], + }, + }, + }; + + await importer.import(302, planned as never, vi.fn().mockResolvedValue(undefined)); + + expect(importRepo.batchUpsertUserBookStatuses).not.toHaveBeenCalled(); + expect(importRepo.batchUpsertReadingProgress).not.toHaveBeenCalled(); + expect(importRepo.batchUpsertAudiobookProgress).not.toHaveBeenCalled(); + expect(importRepo.batchInsertBookmarks).not.toHaveBeenCalled(); + expect(importRepo.batchInsertAnnotations).not.toHaveBeenCalled(); + expect(importRepo.batchInsertCollectionBooks).not.toHaveBeenCalled(); + + expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'user_book_status', expect.objectContaining({ processed: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'reading_progress', expect.objectContaining({ processed: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'audiobook_progress', expect.objectContaining({ processed: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'bookmarks', expect.objectContaining({ processed: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'annotations', expect.objectContaining({ processed: 0 })); + expect(repo.setRunMetric).toHaveBeenCalledWith(302, 'user_state', 'collections', expect.objectContaining({ processed: 0 })); + }); + + it('falls back to timestamped imported collection names when all candidate names are taken', async () => { + const { importer, importRepo } = makeImporter(); + const existingCollections = [ + { id: 1, userId: 10, name: 'Shelf', description: null }, + { id: 2, userId: 10, name: 'Shelf (Booklore)', description: null }, + ]; + for (let i = 2; i < 10_000; i += 1) { + existingCollections.push({ + id: 100 + i, + userId: 10, + name: `Shelf (Booklore ${i})`, + description: null, + }); + } + importRepo.fetchExistingCollections.mockResolvedValue(existingCollections); + importRepo.insertCollection.mockResolvedValue({ id: 9000 }); + + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(12345); + try { + const planned = { + plan: { userMappings: [{ sourceUserId: 'u1', targetUserId: 10 }], pathMappings: [] }, + execution: { + matchedBooks: [], + sourceData: { + availableDomains: { shelves: true }, + books: [], + userBookStatuses: [], + userFileProgress: [], + bookmarks: [], + annotations: [], + shelves: [{ sourceShelfId: 'new-shelf', sourceUserId: 'u1', name: 'Shelf' }], + shelfBooks: [], + }, + }, + }; + + await importer.import(303, planned as never, vi.fn().mockResolvedValue(undefined)); + } finally { + nowSpy.mockRestore(); + } + + expect(importRepo.insertCollection).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Shelf (Booklore 12345)', + }), + ); + }); +}); diff --git a/server/src/modules/migration/migration-profile.service.test.ts b/server/src/modules/migration/migration-profile.service.test.ts index dabbcb5fa..cb8701071 100644 --- a/server/src/modules/migration/migration-profile.service.test.ts +++ b/server/src/modules/migration/migration-profile.service.test.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, ConflictException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { MigrationProfileService } from './migration-profile.service'; @@ -9,44 +9,100 @@ const noopEncryption = { isConfigured: () => false, } as never; -function createProfileService(overrides: Partial> = {}): MigrationProfileService { +function buildSource(id = 1) { + return { + id, + type: 'booklore', + name: 'Booklore', + connectionConfig: { + host: 'db.local', + user: 'booklore', + password: 'secret', + database: 'booklore', + }, + capabilities: null, + lastValidatedAt: new Date(), + createdByUserId: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function buildProfile(id: number, sourceId: number, pathMappings: Array<{ sourcePrefix: string; targetPrefix: string }>) { + return { + id, + sourceId, + name: `Profile ${id}`, + version: 1, + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings, + scope: { preflight: { existing: true } }, + createdByUserId: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function createProfileService(overrides: Partial> = {}) { + const adapter = { + exportData: vi.fn(() => Promise.resolve({ users: [], books: [] })), + }; const repo = { - findSourceById: vi.fn(() => + findSourceById: vi.fn(() => Promise.resolve(buildSource())), + listTargetUsersForMapping: vi.fn(() => + Promise.resolve([ + { id: 10, username: 'target-user', name: 'Target User', email: 'target@example.com' }, + { id: 11, username: 'reader11', name: 'Reader Eleven', email: 'reader11@example.com' }, + { id: 12, username: 'reader12', name: 'Reader Twelve', email: 'reader12@example.com' }, + { id: 13, username: 'reader13', name: 'Reader Thirteen', email: 'reader13@example.com' }, + ]), + ), + listProfiles: vi.fn(() => Promise.resolve([])), + createProfile: vi.fn(() => Promise.resolve({ - id: 1, - type: 'booklore', - name: 'Booklore', - connectionConfig: {}, - capabilities: null, - lastValidatedAt: new Date(), + id: 3, + sourceId: 1, + name: 'Booklore Migration', + version: 1, + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings: [], + scope: {}, createdByUserId: 1, createdAt: new Date(), updatedAt: new Date(), }), ), - listTargetUsersForMapping: vi.fn(() => - Promise.resolve([ - { - id: 10, - username: 'target-user', - name: 'Target User', - email: 'target@example.com', - }, - ]), - ), - listProfiles: vi.fn(() => Promise.resolve([])), - createProfile: vi.fn(), + updateProfileScope: vi.fn(() => Promise.resolve(null)), listRuns: vi.fn(() => Promise.resolve([])), purgeRunState: vi.fn(() => Promise.resolve()), ...overrides, }; - - return new MigrationProfileService(repo as never, { listTypes: vi.fn(), get: vi.fn() } as never, { validate: vi.fn() } as never, noopEncryption); + const adapterRegistry = { + listTypes: vi.fn(), + get: vi.fn(() => adapter), + }; + const pathValidator = { + validate: vi.fn(() => + Promise.resolve({ + summary: { + totalSourceBooks: 1, + booksWithFilePath: 1, + mappedByPrefix: 1, + matchedTargetPaths: 1, + unmatchedTargetPaths: 0, + unchangedPaths: 0, + }, + mappings: [], + }), + ), + }; + const service = new MigrationProfileService(repo as never, adapterRegistry as never, pathValidator as never, noopEncryption); + return { service, repo, adapter, adapterRegistry, pathValidator }; } describe('MigrationProfileService', () => { it('rejects profile creation when target user mapping IDs are invalid', async () => { - const service = createProfileService(); + const { service } = createProfileService(); await expect( service.createProfile( @@ -61,112 +117,203 @@ describe('MigrationProfileService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); - it('creates a new profile version instead of overwriting the current one', async () => { - const createProfile = vi.fn(() => - Promise.resolve({ - id: 3, - sourceId: 1, - name: 'Booklore Migration', - version: 4, - userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], - pathMappings: [], - scope: {}, - createdByUserId: 1, - createdAt: new Date(), - updatedAt: new Date(), - }), - ); - const service = createProfileService({ - listProfiles: vi.fn(() => - Promise.resolve([ - { - id: 2, - sourceId: 1, - name: 'Booklore Migration', - version: 3, - userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], - pathMappings: [], - scope: {}, - createdByUserId: 1, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]), - ), - createProfile, + it('blocks profile creation when a live run is active', async () => { + const { service } = createProfileService({ + listRuns: vi.fn(() => Promise.resolve([{ id: 77, state: 'running' }])), }); - await service.createProfile( + await expect( + service.createProfile( + { + sourceId: 1, + name: 'Profile', + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings: [], + }, + 1, + ), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('throws NotFoundException when creating a profile for missing source', async () => { + const { service } = createProfileService({ + findSourceById: vi.fn(() => Promise.resolve(null)), + }); + + await expect( + service.createProfile( + { + sourceId: 123, + name: 'Profile', + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings: [], + }, + 1, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws InternalServerErrorException when profile persistence fails', async () => { + const { service } = createProfileService({ + createProfile: vi.fn(() => Promise.resolve(null)), + }); + + await expect( + service.createProfile( + { + sourceId: 1, + name: 'Profile', + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings: [], + }, + 1, + ), + ).rejects.toBeInstanceOf(InternalServerErrorException); + }); + + it('normalizes createProfile input, purges run state, and returns sanitized profile fields', async () => { + const createdProfile = { + id: 8, + sourceId: 1, + name: 'My Profile', + version: 1, + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings: [], + scope: {}, + createdByUserId: 9, + createdAt: new Date(), + updatedAt: new Date(), + }; + const createProfile = vi.fn(() => Promise.resolve(createdProfile)); + const { service, repo } = createProfileService({ createProfile }); + + const result = await service.createProfile( { sourceId: 1, - name: 'Booklore Migration', + name: ' My Profile ', userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], - pathMappings: [], - }, - 1, + } as never, + 9, ); expect(createProfile).toHaveBeenCalledWith( expect.objectContaining({ sourceId: 1, - name: 'Booklore Migration', - }), - ); - expect(createProfile).toHaveBeenCalledWith( - expect.not.objectContaining({ - version: expect.anything(), - }), - ); - }); - - it('does not pass version to createProfile (repo handles it)', async () => { - const createProfileFn = vi.fn(() => - Promise.resolve({ - id: 5, - sourceId: 1, - name: 'Booklore Migration', - version: 1, - userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + name: 'My Profile', pathMappings: [], scope: {}, - createdByUserId: 1, - createdAt: new Date(), - updatedAt: new Date(), + createdByUserId: 9, }), ); + expect(repo.purgeRunState).toHaveBeenCalledWith(1); + expect(result).toEqual({ + id: 8, + sourceId: 1, + name: 'My Profile', + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings: [], + scope: {}, + createdAt: createdProfile.createdAt, + updatedAt: createdProfile.updatedAt, + }); + }); - const service = createProfileService({ - listProfiles: vi.fn(() => - Promise.resolve([ - { - id: 3, - sourceId: 1, - name: 'Booklore Migration', - version: 3, - userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], - pathMappings: [], - scope: {}, - createdByUserId: 1, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]), - ), - createProfile: createProfileFn, + it('suggestUserMappings ranks and limits candidates based on score confidence', async () => { + const { service, adapter, adapterRegistry } = createProfileService(); + adapter.exportData.mockResolvedValue({ + users: [ + { sourceUserId: 'u-email', username: 'any', email: 'reader11@example.com', name: 'No Match' }, + { sourceUserId: 'u-username', username: 'reader12', email: null, name: null }, + { sourceUserId: 'u-name', username: 'zzz', email: null, name: 'Reader Thirteen' }, + { sourceUserId: 'u-top3', username: 'reader', email: null, name: 'Reader' }, + ], + books: [], }); - await service.createProfile( - { - sourceId: 1, - name: 'Booklore Migration', - userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], - pathMappings: [], - }, - 1, - ); + const result = await service.suggestUserMappings(1); - expect(createProfileFn).toHaveBeenCalledTimes(1); - const callArg = (createProfileFn.mock.calls as unknown[][])[0][0] as Record; - expect(callArg).not.toHaveProperty('version'); + expect(adapterRegistry.get).toHaveBeenCalledWith('booklore'); + const [emailSuggestion, usernameSuggestion, nameSuggestion, topThreeSuggestion] = result.suggestions; + expect(emailSuggestion.suggestedTargetUserId).toBe(11); + expect(emailSuggestion.confidence).toBe('high'); + expect(usernameSuggestion.suggestedTargetUserId).toBe(12); + expect(usernameSuggestion.confidence).toBe('high'); + expect(nameSuggestion.suggestedTargetUserId).toBe(13); + expect(nameSuggestion.confidence).toBe('medium'); + expect(topThreeSuggestion.candidates).toHaveLength(3); + }); + + it('throws NotFoundException when suggestUserMappings source is missing', async () => { + const { service } = createProfileService({ + findSourceById: vi.fn(() => Promise.resolve(null)), + }); + + await expect(service.suggestUserMappings(55)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('validatePathMappings persists preflight metadata for matching profile hash', async () => { + const matchingPathMappings = [{ sourcePrefix: '/source', targetPrefix: '/target' }]; + const updateProfileScope = vi.fn(() => Promise.resolve(buildProfile(4, 1, matchingPathMappings))); + const { service, repo, adapter, pathValidator } = createProfileService({ + listProfiles: vi.fn(() => + Promise.resolve([buildProfile(3, 1, [{ sourcePrefix: '/else', targetPrefix: '/other' }]), buildProfile(4, 1, matchingPathMappings)]), + ), + updateProfileScope, + }); + adapter.exportData.mockResolvedValue({ + users: [], + books: [{ sourceBookId: 'b1', filePath: '/source/book.epub' }], + }); + + const result = await service.validatePathMappings(1, { + pathMappings: matchingPathMappings, + sampleLimit: 3, + }); + + expect(pathValidator.validate).toHaveBeenCalledWith( + expect.objectContaining({ + sourceBooks: [{ sourceBookId: 'b1', filePath: '/source/book.epub' }], + pathMappings: matchingPathMappings, + sampleLimit: 3, + }), + ); + expect(updateProfileScope).toHaveBeenCalledWith( + 4, + expect.objectContaining({ + preflight: expect.objectContaining({ + existing: true, + pathValidatedAt: result.validatedAt, + pathMappingsHash: result.pathMappingsHash, + }), + }), + ); + expect(result.persistedProfileId).toBe(4); + expect(repo.findSourceById).toHaveBeenCalledWith(1); + }); + + it('returns persistedProfileId null when no profile has matching path mapping hash', async () => { + const { service, repo, adapter } = createProfileService({ + listProfiles: vi.fn(() => Promise.resolve([buildProfile(4, 1, [{ sourcePrefix: '/x', targetPrefix: '/y' }])])), + }); + adapter.exportData.mockResolvedValue({ users: [], books: [] }); + + const result = await service.validatePathMappings(1, { + pathMappings: [{ sourcePrefix: '/source', targetPrefix: '/target' }], + }); + + expect(result.persistedProfileId).toBeNull(); + expect(repo.updateProfileScope).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when validatePathMappings source is missing', async () => { + const { service } = createProfileService({ + findSourceById: vi.fn(() => Promise.resolve(null)), + }); + + await expect( + service.validatePathMappings(42, { + pathMappings: [{ sourcePrefix: '/source', targetPrefix: '/target' }], + }), + ).rejects.toBeInstanceOf(NotFoundException); }); }); diff --git a/server/src/modules/migration/migration-progress.gateway.test.ts b/server/src/modules/migration/migration-progress.gateway.test.ts new file mode 100644 index 000000000..34cc4f43f --- /dev/null +++ b/server/src/modules/migration/migration-progress.gateway.test.ts @@ -0,0 +1,181 @@ +import { Permission } from '@projectx/types'; + +import type { RequestUser } from '../../common/types/request-user'; +import { MigrationProgressGateway } from './migration-progress.gateway'; + +function makeUser(overrides?: Partial): RequestUser { + return { + id: 7, + username: 'admin', + name: 'Admin', + email: null, + active: true, + isSuperuser: false, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + ...overrides, + }; +} + +function makeGateway() { + const jwtService = { + verify: vi.fn(), + }; + const authService = { + validateUser: vi.fn(), + }; + const repo = { + findRunById: vi.fn(), + listRunMetrics: vi.fn(), + }; + + const gateway = new MigrationProgressGateway(jwtService as never, authService as never, repo as never); + return { gateway, jwtService, authService, repo }; +} + +function makeClient() { + return { + id: 'socket-1', + handshake: { auth: { token: 'token-1' } }, + data: {}, + disconnect: vi.fn(), + join: vi.fn(), + emit: vi.fn(), + }; +} + +describe('MigrationProgressGateway', () => { + it('accepts websocket connections for authorized users', async () => { + const { gateway, jwtService, authService } = makeGateway(); + const client = makeClient(); + jwtService.verify.mockReturnValue({ sub: 7, ver: 1 }); + authService.validateUser.mockResolvedValue(makeUser({ permissions: [Permission.ManageAppSettings] })); + + await gateway.handleConnection(client as never); + + expect(jwtService.verify).toHaveBeenCalledWith('token-1'); + expect(authService.validateUser).toHaveBeenCalledWith(7, 1); + expect(client.disconnect).not.toHaveBeenCalled(); + expect((client.data as { user?: RequestUser }).user?.id).toBe(7); + }); + + it('rejects websocket connections when token is missing or permission is absent', async () => { + const { gateway, jwtService, authService } = makeGateway(); + const missingTokenClient = makeClient(); + missingTokenClient.handshake.auth = {}; + + await gateway.handleConnection(missingTokenClient as never); + expect(missingTokenClient.disconnect).toHaveBeenCalledTimes(1); + + const forbiddenClient = makeClient(); + jwtService.verify.mockReturnValue({ sub: 7, ver: 1 }); + authService.validateUser.mockResolvedValue(makeUser({ permissions: [] })); + + await gateway.handleConnection(forbiddenClient as never); + expect(forbiddenClient.disconnect).toHaveBeenCalledTimes(1); + }); + + it('subscribes to run room and emits computed progress snapshot', async () => { + const { gateway, repo } = makeGateway(); + const client = makeClient(); + (client.data as { user: RequestUser }).user = makeUser({ permissions: [Permission.ManageAppSettings] }); + repo.findRunById.mockResolvedValue({ + id: 12, + sourceId: 1, + profileId: 2, + planArtifactId: 3, + state: 'running', + currentStage: 'user_state', + targetKey: 'default', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + errorMessage: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }); + repo.listRunMetrics.mockResolvedValue([ + { + stage: 'shared_overlays', + entityType: 'book_metadata', + processed: 3, + imported: 2, + skipped: 1, + unresolved: 0, + failed: 0, + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }, + { + stage: 'user_state', + entityType: 'reading_progress', + processed: 5, + imported: 4, + skipped: 0, + unresolved: 1, + failed: 0, + updatedAt: new Date('2026-01-02T00:01:00.000Z'), + }, + ]); + + await gateway.handleSubscribeRun(client as never, 12); + + expect(client.join).toHaveBeenCalledWith('run:12'); + expect(client.emit).toHaveBeenCalledWith( + 'migration:progress', + expect.objectContaining({ + runId: 12, + state: 'running', + currentStage: 'user_state', + totals: { + processed: 8, + imported: 6, + skipped: 1, + unresolved: 1, + failed: 0, + }, + }), + ); + }); + + it('does nothing on subscribe when no authenticated user or run exists', async () => { + const { gateway, repo } = makeGateway(); + const noUserClient = makeClient(); + + await gateway.handleSubscribeRun(noUserClient as never, 4); + expect(noUserClient.join).not.toHaveBeenCalled(); + + const client = makeClient(); + (client.data as { user: RequestUser }).user = makeUser({ permissions: [Permission.ManageAppSettings] }); + repo.findRunById.mockResolvedValue(null); + + await gateway.handleSubscribeRun(client as never, 4); + expect(client.join).toHaveBeenCalledWith('run:4'); + expect(client.emit).not.toHaveBeenCalled(); + }); + + it('emits progress updates to room-scoped subscribers', () => { + const { gateway } = makeGateway(); + const emit = vi.fn(); + const to = vi.fn().mockReturnValue({ emit }); + (gateway as { server: { to: typeof to } }).server = { to }; + + gateway.emitProgress({ + runId: 5, + state: 'running', + currentStage: 'shared_overlays', + totals: { processed: 1, imported: 1, skipped: 0, unresolved: 0, failed: 0 }, + metrics: [], + }); + + expect(to).toHaveBeenCalledWith('run:5'); + expect(emit).toHaveBeenCalledWith( + 'migration:progress', + expect.objectContaining({ + runId: 5, + }), + ); + }); +}); diff --git a/server/src/modules/migration/migration-source.service.test.ts b/server/src/modules/migration/migration-source.service.test.ts index bf011ef4e..cc7b4f98c 100644 --- a/server/src/modules/migration/migration-source.service.test.ts +++ b/server/src/modules/migration/migration-source.service.test.ts @@ -1,192 +1,321 @@ +import { BadRequestException, ConflictException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { MigrationSourceService, resolveConnectionConfig } from './migration-source.service'; const noopEncryption = { - encryptConfig: (c: Record) => c, - decryptConfig: (c: unknown) => c as Record, + encryptConfig: vi.fn((c: Record) => c), + decryptConfig: vi.fn((c: unknown) => c as Record), isConfigured: () => false, } as never; +function buildSource(overrides: Partial> = {}) { + return { + id: 1, + type: 'booklore', + name: 'Existing Source', + connectionConfig: { host: 'db.example.com', user: 'admin', password: 'real-secret-password', database: 'booklore' }, + capabilities: null, + lastValidatedAt: new Date(), + createdByUserId: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function validationResult(ok: boolean) { + return { + ok, + sourceType: 'booklore', + sourceVersion: '1.0', + warnings: ok ? [] : ['warning'], + counts: { books: 12 }, + missingTables: ok ? [] : ['books'], + }; +} + +function createService(overrides: Partial> = {}) { + const adapter = { + validate: vi.fn(() => Promise.resolve(validationResult(true))), + fetchPathPrefixes: vi.fn(() => Promise.resolve(['/library'])), + }; + const repo = { + findSourceById: vi.fn(() => Promise.resolve(buildSource())), + listRuns: vi.fn(() => Promise.resolve([])), + listSources: vi.fn(() => Promise.resolve([buildSource()])), + updateSource: vi.fn((id: number, values: Record) => Promise.resolve({ ...buildSource(), ...values, id })), + createSource: vi.fn((values: Record) => Promise.resolve({ ...buildSource({ id: 5 }), ...values })), + updateSourceValidation: vi.fn(() => Promise.resolve(buildSource())), + purgeRunState: vi.fn(() => Promise.resolve()), + ...overrides, + }; + const adapterRegistry = { + listTypes: vi.fn(() => ['booklore']), + get: vi.fn(() => adapter), + }; + const service = new MigrationSourceService(repo as never, adapterRegistry as never, noopEncryption); + return { service, repo, adapterRegistry, adapter }; +} + describe('MigrationSourceService', () => { - describe('createSource - password sentinel', () => { - it('preserves existing password when sentinel is sent back', async () => { - const existingSource = { - id: 1, - type: 'booklore', - name: 'Existing Source', - connectionConfig: { host: 'db.example.com', user: 'admin', password: 'real-secret-password', database: 'booklore' }, - capabilities: null, - lastValidatedAt: new Date(), - createdByUserId: 1, - createdAt: new Date(), - updatedAt: new Date(), - }; + it('lists supported source types from the adapter registry', () => { + const { service } = createService(); + expect(service.listSupportedSourceTypes()).toEqual(['booklore']); + }); - const updateSource = vi.fn((id: number, values: Record) => { - return Promise.resolve({ ...existingSource, ...values, id }); - }); + it('tests a source through the matching adapter', async () => { + const { service, adapterRegistry, adapter } = createService(); + const result = await service.testSource({ + type: 'booklore', + connectionConfig: { host: 'db.local', user: 'u', password: 'pw', database: 'booklore' }, + }); - const validate = vi.fn(() => - Promise.resolve({ + expect(adapterRegistry.get).toHaveBeenCalledWith('booklore'); + expect(adapter.validate).toHaveBeenCalled(); + expect(result.ok).toBe(true); + }); + + it('validateSourceById persists successful validation payload and clears stale run state', async () => { + const updateSourceValidation = vi.fn(() => Promise.resolve(buildSource())); + const { service, repo } = createService({ updateSourceValidation }); + + const result = await service.validateSourceById(1); + + expect(result.ok).toBe(true); + expect(updateSourceValidation).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + capabilities: expect.objectContaining({ ok: true, sourceType: 'booklore', sourceVersion: '1.0', - warnings: [], - counts: {}, - missingTables: [], }), - ); + lastValidatedAt: expect.any(Date), + }), + ); + expect(repo.purgeRunState).toHaveBeenCalledWith(1); + }); - const repo = { - findSourceById: vi.fn(() => Promise.resolve(existingSource)), - listRuns: vi.fn(() => Promise.resolve([])), - listSources: vi.fn(() => Promise.resolve([existingSource])), - updateSource, - createSource: vi.fn(), - purgeRunState: vi.fn(() => Promise.resolve()), - }; + it('validateSourceById stores null lastValidatedAt when validation fails', async () => { + const updateSourceValidation = vi.fn(() => Promise.resolve(buildSource())); + const { service, adapter, repo } = createService({ updateSourceValidation }); + adapter.validate.mockResolvedValue(validationResult(false)); - const service = new MigrationSourceService(repo as never, { listTypes: vi.fn(), get: () => ({ validate }) } as never, noopEncryption); + const result = await service.validateSourceById(1); - await service.createSource( - { - type: 'booklore', - name: 'Existing Source', - connectionConfig: { host: 'db.example.com', user: 'admin', password: '********', database: 'booklore' }, - }, - 1, - ); + expect(result.ok).toBe(false); + expect(updateSourceValidation).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + capabilities: expect.objectContaining({ + ok: false, + missingTables: ['books'], + }), + lastValidatedAt: null, + }), + ); + expect(repo.purgeRunState).toHaveBeenCalledWith(1); + }); - const calledConfig = updateSource.mock.calls[0][1].connectionConfig as Record; - expect(calledConfig.password).toBe('real-secret-password'); + it('throws NotFoundException when validateSourceById source is missing', async () => { + const { service } = createService({ + findSourceById: vi.fn(() => Promise.resolve(null)), }); - it('uses new password when non-sentinel value is sent', async () => { - const existingSource = { - id: 1, + await expect(service.validateSourceById(999)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('createSource updates existing source and preserves sentinel password', async () => { + const existingSource = buildSource({ + id: 7, + connectionConfig: { + host: 'db.example.com', + user: 'admin', + password: 'keep-this', + database: 'booklore', + }, + }); + const updateSource = vi.fn((id: number, values: Record) => Promise.resolve({ ...existingSource, ...values, id })); + const { service } = createService({ + listSources: vi.fn(() => + Promise.resolve([ + existingSource, + buildSource({ + id: 8, + name: 'Existing Source 2', + connectionConfig: { host: 'db.example.com', user: 'admin', password: 'x', database: 'booklore' }, + }), + ]), + ), + updateSource, + }); + + const result = await service.createSource( + { type: 'booklore', - name: 'Existing Source', - connectionConfig: { host: 'db.example.com', user: 'admin', password: 'old-password', database: 'booklore' }, - capabilities: null, - lastValidatedAt: new Date(), - createdByUserId: 1, - createdAt: new Date(), - updatedAt: new Date(), - }; + name: 'Existing Source 2', + connectionConfig: { + host: 'db.example.com', + user: 'admin', + password: '********', + database: 'booklore', + }, + }, + 42, + ); - const updateSource = vi.fn((id: number, values: Record) => { - return Promise.resolve({ ...existingSource, ...values, id }); - }); + const calledConfig = updateSource.mock.calls[0][1].connectionConfig as Record; + expect(calledConfig.password).toBe('keep-this'); + expect(updateSource).toHaveBeenCalledWith( + 7, + expect.objectContaining({ + type: 'booklore', + name: 'Existing Source 2 2', + createdByUserId: 42, + }), + ); + expect((result.connectionConfig as Record).password).toBe('********'); + }); - const validate = vi.fn(() => - Promise.resolve({ - ok: true, - sourceType: 'booklore', - sourceVersion: '1.0', - warnings: [], - counts: {}, - missingTables: [], - }), - ); + it('createSource inserts a new source when no existing source exists', async () => { + const createSource = vi.fn((values: Record) => Promise.resolve({ ...buildSource({ id: 11 }), ...values })); + const { service, repo } = createService({ + listSources: vi.fn(() => Promise.resolve([])), + createSource, + }); - const repo = { - findSourceById: vi.fn(() => Promise.resolve(existingSource)), - listRuns: vi.fn(() => Promise.resolve([])), - listSources: vi.fn(() => Promise.resolve([existingSource])), - updateSource, - createSource: vi.fn(), - purgeRunState: vi.fn(() => Promise.resolve()), - }; + const result = await service.createSource( + { + type: ' BOOKLORE ', + name: ' New Source ', + connectionConfig: { + host: 'db.example.com', + user: 'admin', + password: 'new-password', + database: 'booklore', + }, + }, + 2, + ); - const service = new MigrationSourceService(repo as never, { listTypes: vi.fn(), get: () => ({ validate }) } as never, noopEncryption); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'booklore', + name: 'New Source', + createdByUserId: 2, + }), + ); + expect(repo.purgeRunState).toHaveBeenCalledWith(11); + expect((result.connectionConfig as Record).password).toBe('********'); + }); - await service.createSource( + it('createSource rejects invalid adapter validation results', async () => { + const { service, adapter } = createService({ + listSources: vi.fn(() => Promise.resolve([])), + }); + adapter.validate.mockResolvedValue(validationResult(false)); + + await expect( + service.createSource( { type: 'booklore', - name: 'Existing Source', - connectionConfig: { host: 'db.example.com', user: 'admin', password: 'new-password', database: 'booklore' }, + name: 'Source', + connectionConfig: { + host: 'db.example.com', + user: 'admin', + password: 'pw', + database: 'booklore', + }, }, 1, - ); - - const calledConfig = updateSource.mock.calls[0][1].connectionConfig as Record; - expect(calledConfig.password).toBe('new-password'); - }); + ), + ).rejects.toBeInstanceOf(BadRequestException); }); - describe('resolveConnectionConfig - adapter-agnostic sentinel', () => { - it('resolves sentinel in any field named with password-like keys', async () => { - const existingSource = { - id: 1, - type: 'custom_source', - name: 'Custom Source', - connectionConfig: { host: 'db.example.com', user: 'admin', password: 'real-secret', apiKey: 'real-api-key', database: 'mydb' }, - capabilities: null, - lastValidatedAt: new Date(), - createdByUserId: 1, - createdAt: new Date(), - updatedAt: new Date(), - }; + it('createSource blocks when a migration run is already active', async () => { + const { service } = createService({ + listRuns: vi.fn(() => Promise.resolve([{ id: 99, state: 'running' }])), + }); - const updateSource = vi.fn((id: number, values: Record) => { - return Promise.resolve({ ...existingSource, ...values, id }); - }); - - const validate = vi.fn(() => - Promise.resolve({ - ok: true, - sourceType: 'custom', - sourceVersion: '1.0', - warnings: [], - counts: {}, - missingTables: [], - }), - ); - - const repo = { - findSourceById: vi.fn(() => Promise.resolve(existingSource)), - listRuns: vi.fn(() => Promise.resolve([])), - listSources: vi.fn(() => Promise.resolve([existingSource])), - updateSource, - createSource: vi.fn(), - purgeRunState: vi.fn(() => Promise.resolve()), - }; - - const service = new MigrationSourceService(repo as never, { listTypes: vi.fn(), get: () => ({ validate }) } as never, noopEncryption); - - await service.createSource( + await expect( + service.createSource( { - type: 'custom_source', - name: 'Custom Source', - connectionConfig: { host: 'db.example.com', user: 'admin', password: '********', apiKey: '********', database: 'mydb' }, + type: 'booklore', + name: 'Source', + connectionConfig: { + host: 'db.example.com', + user: 'admin', + password: 'pw', + database: 'booklore', + }, }, 1, - ); - - const calledConfig = updateSource.mock.calls[0][1].connectionConfig as Record; - expect(calledConfig.password).toBe('real-secret'); - expect(calledConfig.apiKey).toBe('real-api-key'); - expect(calledConfig.host).toBe('db.example.com'); - }); + ), + ).rejects.toBeInstanceOf(ConflictException); }); - describe('resolveConnectionConfig (standalone)', () => { + it('createSource throws InternalServerErrorException when persistence returns null', async () => { + const { service } = createService({ + listSources: vi.fn(() => Promise.resolve([])), + createSource: vi.fn(() => Promise.resolve(null)), + }); + + await expect( + service.createSource( + { + type: 'booklore', + name: 'Source', + connectionConfig: { + host: 'db.example.com', + user: 'admin', + password: 'pw', + database: 'booklore', + }, + }, + 1, + ), + ).rejects.toBeInstanceOf(InternalServerErrorException); + }); + + it('getSourcePathPrefixes returns empty prefixes when adapter does not support path fetching', async () => { + const { service, adapterRegistry } = createService(); + adapterRegistry.get.mockReturnValue({ + validate: vi.fn(() => Promise.resolve(validationResult(true))), + }); + + await expect(service.getSourcePathPrefixes(1)).resolves.toEqual({ prefixes: [] }); + }); + + it('getSourcePathPrefixes fetches prefixes through adapter when supported', async () => { + const { service } = createService(); + + await expect(service.getSourcePathPrefixes(1)).resolves.toEqual({ prefixes: ['/library'] }); + }); + + it('getSourcePathPrefixes throws NotFoundException for missing source', async () => { + const { service } = createService({ + findSourceById: vi.fn(() => Promise.resolve(null)), + }); + + await expect(service.getSourcePathPrefixes(404)).rejects.toBeInstanceOf(NotFoundException); + }); + + describe('resolveConnectionConfig', () => { it('returns incoming config when no existing source', () => { const result = resolveConnectionConfig({ host: 'localhost', password: 'secret' }, null, noopEncryption); expect(result).toEqual({ host: 'localhost', password: 'secret' }); }); - it('replaces sentinel values from existing config', () => { - const existing = { - id: 1, - type: 'booklore', - name: 'test', + it('replaces sentinels from existing config and uses empty string when key is missing', () => { + const existing = buildSource({ connectionConfig: { host: 'db.local', password: 'real-pw' }, - } as never; + }) as never; + + const result = resolveConnectionConfig({ host: 'db.local', password: '********', apiKey: '********' }, existing, noopEncryption); - const result = resolveConnectionConfig({ host: 'db.local', password: '********' }, existing, noopEncryption); expect(result.password).toBe('real-pw'); + expect(result.apiKey).toBe(''); expect(result.host).toBe('db.local'); }); }); diff --git a/server/src/modules/migration/migration.controller.test.ts b/server/src/modules/migration/migration.controller.test.ts new file mode 100644 index 000000000..a01c20edc --- /dev/null +++ b/server/src/modules/migration/migration.controller.test.ts @@ -0,0 +1,139 @@ +import type { RequestUser } from '../../common/types/request-user'; +import { MigrationController } from './migration.controller'; + +function makeUser(overrides?: Partial): RequestUser { + return { + id: 42, + username: 'admin', + name: 'Admin', + email: null, + active: true, + isSuperuser: true, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + ...overrides, + }; +} + +function makeReply() { + const headers: Record = {}; + const reply = { + header: vi.fn(), + send: vi.fn(), + }; + reply.header.mockImplementation((key: string, value: unknown) => { + headers[key] = value; + return reply as never; + }); + reply.send.mockImplementation((payload: unknown) => payload as never); + + return { reply: reply as never, headers }; +} + +function makeController() { + const sourceService = { + listSupportedSourceTypes: vi.fn(), + testSource: vi.fn(), + createSource: vi.fn(), + validateSourceById: vi.fn(), + getSourcePathPrefixes: vi.fn(), + }; + const profileService = { + listTargetUsers: vi.fn(), + suggestUserMappings: vi.fn(), + validatePathMappings: vi.fn(), + createProfile: vi.fn(), + }; + const runService = { + getWorkflowState: vi.fn(), + createDryRunPlan: vi.fn(), + resolveDuplicateMatches: vi.fn(), + startLiveRun: vi.fn(), + cancelRun: vi.fn(), + retryFailedRun: vi.fn(), + getRunProgress: vi.fn(), + getRunReport: vi.fn(), + exportRunReport: vi.fn(), + }; + + return { + controller: new MigrationController(sourceService as never, profileService as never, runService as never), + sourceService, + profileService, + runService, + }; +} + +describe('MigrationController', () => { + it('delegates source, profile, and run endpoints', async () => { + const { controller, sourceService, profileService, runService } = makeController(); + const user = makeUser({ id: 7 }); + + sourceService.listSupportedSourceTypes.mockResolvedValue(['booklore']); + sourceService.testSource.mockResolvedValue({ ok: true }); + runService.getWorkflowState.mockResolvedValue({ running: false }); + profileService.listTargetUsers.mockResolvedValue([{ id: 1 }]); + sourceService.createSource.mockResolvedValue({ id: 3 }); + sourceService.validateSourceById.mockResolvedValue({ ok: true }); + sourceService.getSourcePathPrefixes.mockResolvedValue(['/media/books']); + profileService.suggestUserMappings.mockResolvedValue([{ sourceUserId: 'u1', targetUserId: 1 }]); + profileService.validatePathMappings.mockResolvedValue({ valid: true, sample: [] }); + profileService.createProfile.mockResolvedValue({ id: 5 }); + runService.createDryRunPlan.mockResolvedValue({ id: 9 }); + runService.resolveDuplicateMatches.mockResolvedValue({ resolved: 1 }); + runService.startLiveRun.mockResolvedValue({ id: 11 }); + runService.cancelRun.mockResolvedValue({ cancelled: true }); + runService.retryFailedRun.mockResolvedValue({ retried: true }); + runService.getRunProgress.mockResolvedValue({ state: 'running' }); + runService.getRunReport.mockResolvedValue({ runId: 11 }); + + await expect(controller.listSupportedTypes()).resolves.toEqual(['booklore']); + await expect(controller.testSource({ type: 'booklore' } as never)).resolves.toEqual({ ok: true }); + await expect(controller.getWorkflowState()).resolves.toEqual({ running: false }); + await expect(controller.listTargetUsers()).resolves.toEqual([{ id: 1 }]); + + await expect(controller.createSource({ type: 'booklore' } as never, user)).resolves.toEqual({ id: 3 }); + expect(sourceService.createSource).toHaveBeenCalledWith({ type: 'booklore' }, 7); + + await expect(controller.validateSourceById(3)).resolves.toEqual({ ok: true }); + await expect(controller.getSourcePathPrefixes(3)).resolves.toEqual(['/media/books']); + await expect(controller.suggestUserMappings(3)).resolves.toEqual([{ sourceUserId: 'u1', targetUserId: 1 }]); + await expect(controller.validatePathMappings(3, { pathMappings: [] } as never)).resolves.toEqual({ valid: true, sample: [] }); + await expect(controller.createProfile({ sourceId: 3 } as never, user)).resolves.toEqual({ id: 5 }); + expect(profileService.createProfile).toHaveBeenCalledWith({ sourceId: 3 }, 7); + + await expect(controller.createDryRunPlan({ profileId: 5 } as never, user)).resolves.toEqual({ id: 9 }); + expect(runService.createDryRunPlan).toHaveBeenCalledWith({ profileId: 5 }, 7); + + await expect(controller.resolveDuplicateMatches(9, { resolutions: [] } as never)).resolves.toEqual({ resolved: 1 }); + await expect(controller.startLiveRun({ planArtifactId: 9 } as never, user)).resolves.toEqual({ id: 11 }); + expect(runService.startLiveRun).toHaveBeenCalledWith({ planArtifactId: 9 }, 7); + + await expect(controller.cancelRun(11)).resolves.toEqual({ cancelled: true }); + await expect(controller.retryRun(11)).resolves.toEqual({ retried: true }); + await expect(controller.getRunProgress(11)).resolves.toEqual({ state: 'running' }); + await expect(controller.getRunReport(11)).resolves.toEqual({ runId: 11 }); + }); + + it('exports run report with attachment headers from service response', async () => { + const { controller, runService } = makeController(); + const { reply, headers } = makeReply(); + + runService.exportRunReport.mockResolvedValue({ + contentType: 'application/json', + fileName: 'migration-run-4-report.json', + content: '{"ok":true}', + }); + + const result = await controller.exportRunReport(4, 'json', reply); + + expect(runService.exportRunReport).toHaveBeenCalledWith(4, 'json'); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['Content-Disposition']).toBe('attachment; filename="migration-run-4-report.json"'); + expect(result).toBe('{"ok":true}'); + }); +}); diff --git a/server/src/modules/migration/migration.repository.test.ts b/server/src/modules/migration/migration.repository.test.ts new file mode 100644 index 000000000..319e7eb56 --- /dev/null +++ b/server/src/modules/migration/migration.repository.test.ts @@ -0,0 +1,267 @@ +import * as schema from '../../db/schema'; +import { MigrationRepository } from './migration.repository'; + +describe('MigrationRepository', () => { + it('deduplicates path lookups and queries in chunks of 500', async () => { + const where = vi + .fn() + .mockResolvedValueOnce([{ absolutePath: '/books/1.epub' }]) + .mockResolvedValueOnce([{ absolutePath: '/books/2.epub' }]) + .mockResolvedValueOnce([{ absolutePath: '/books/3.epub' }]); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + + const repo = new MigrationRepository({ select } as never); + const inputPaths = Array.from({ length: 1001 }, (_, i) => `/books/${i}.epub`); + inputPaths.push('/books/1.epub', '', ' '); + + const existing = await repo.findExistingBookFilePaths(inputPaths); + + expect(where).toHaveBeenCalledTimes(3); + expect(existing).toEqual(new Set(['/books/1.epub', '/books/2.epub', '/books/3.epub'])); + }); + + it('short-circuits path lookup when no non-empty input paths are provided', async () => { + const select = vi.fn(); + const repo = new MigrationRepository({ select } as never); + + await expect(repo.findExistingBookFilePaths(['', ' '])).resolves.toEqual(new Set()); + expect(select).not.toHaveBeenCalled(); + }); + + it('purges run state and removes all plan artifacts when there are no completed runs', async () => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where: deleteWhere }); + + const selectWhere = vi.fn().mockResolvedValue([]); + const selectFrom = vi.fn().mockReturnValue({ where: selectWhere }); + const select = vi.fn().mockReturnValue({ from: selectFrom }); + + const repo = new MigrationRepository({ delete: deleteFn, select } as never); + await repo.purgeRunState(7); + + expect(deleteFn).toHaveBeenNthCalledWith(1, schema.migrationRuns); + expect(deleteFn).toHaveBeenNthCalledWith(2, schema.migrationPlanArtifacts); + expect(deleteWhere).toHaveBeenCalledTimes(2); + }); + + it('retains completed-run artifacts while purging transient state', async () => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where: deleteWhere }); + + const selectWhere = vi.fn().mockResolvedValue([{ planArtifactId: 10 }, { planArtifactId: null }]); + const selectFrom = vi.fn().mockReturnValue({ where: selectWhere }); + const select = vi.fn().mockReturnValue({ from: selectFrom }); + + const repo = new MigrationRepository({ delete: deleteFn, select } as never); + await repo.purgeRunState(8); + + expect(deleteWhere).toHaveBeenCalledTimes(2); + }); + + it('returns active run without creating a new run when lock finds one', async () => { + const activeRun = { id: 99, state: 'running' }; + + const limit = vi.fn().mockResolvedValue([activeRun]); + const orderBy = vi.fn().mockReturnValue({ limit }); + const where = vi.fn().mockReturnValue({ orderBy }); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select, + delete: vi.fn(), + insert: vi.fn(), + }; + const db = { + transaction: vi.fn().mockImplementation(async (callback: (value: typeof tx) => Promise) => callback(tx)), + }; + + const repo = new MigrationRepository(db as never); + const result = await repo.createRunWithLock({ + sourceId: 1, + profileId: 2, + planArtifactId: 3, + targetKey: 'projectx', + state: 'running', + currentStage: 'init', + triggeredByUserId: 7, + startedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + expect(result).toEqual({ run: null, activeRun }); + expect(tx.delete).not.toHaveBeenCalled(); + expect(tx.insert).not.toHaveBeenCalled(); + }); + + it('creates a new run when lock has no active run and defaults target key', async () => { + const limit = vi.fn().mockResolvedValue([]); + const orderBy = vi.fn().mockReturnValue({ limit }); + const where = vi.fn().mockReturnValue({ orderBy }); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + + const insertReturning = vi.fn().mockResolvedValue([{ id: 101, targetKey: 'projectx' }]); + const insertValues = vi.fn().mockReturnValue({ returning: insertReturning }); + const insert = vi.fn().mockReturnValue({ values: insertValues }); + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where: deleteWhere }); + + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select, + delete: deleteFn, + insert, + }; + const db = { + transaction: vi.fn().mockImplementation(async (callback: (value: typeof tx) => Promise) => callback(tx)), + }; + + const repo = new MigrationRepository(db as never); + const result = await repo.createRunWithLock({ + sourceId: 1, + profileId: 2, + planArtifactId: 3, + targetKey: null, + state: 'running', + currentStage: 'init', + triggeredByUserId: 7, + startedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + targetKey: 'projectx', + }), + ); + expect(result).toEqual({ + run: { id: 101, targetKey: 'projectx' }, + activeRun: null, + }); + }); + + it('handles insert/update/find wrappers and null fallbacks', async () => { + const insertReturning = vi + .fn() + .mockResolvedValueOnce([{ id: 1 }]) + .mockResolvedValueOnce([{ id: 2 }]) + .mockResolvedValueOnce([{ id: 3 }]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning: insertReturning }); + const insertValues = vi.fn().mockReturnValue({ returning: insertReturning, onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values: insertValues }); + + const updateReturning = vi + .fn() + .mockResolvedValueOnce([{ id: 10 }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 12 }]) + .mockResolvedValueOnce([{ id: 14 }]) + .mockResolvedValueOnce([]); + const updateWhere = vi.fn().mockReturnValue({ returning: updateReturning }); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const update = vi.fn().mockReturnValue({ set: updateSet }); + + const query = { + migrationSources: { findFirst: vi.fn(() => Promise.resolve({ id: 100 })) }, + migrationProfiles: { findFirst: vi.fn(() => Promise.resolve({ id: 200 })) }, + migrationPlanArtifacts: { findFirst: vi.fn(() => Promise.resolve({ id: 300 })) }, + migrationRuns: { findFirst: vi.fn(() => Promise.resolve({ id: 400 })) }, + }; + const repo = new MigrationRepository({ insert, update, query } as never); + + await expect( + repo.createSource({ type: 'booklore', name: 'Source', connectionConfig: {}, capabilities: {}, createdByUserId: 1 } as never), + ).resolves.toEqual({ + id: 1, + }); + await expect( + repo.createPlanArtifact({ + sourceId: 1, + profileId: 2, + sourceSnapshotHash: 'a', + profileHash: 'b', + planHash: 'c', + plan: {}, + sourceData: {}, + summary: {}, + createdByUserId: 1, + } as never), + ).resolves.toEqual({ id: 2 }); + await expect( + repo.setRunMetric(1, 'user_state', 'bookmarks', { + processed: 1, + imported: 1, + }), + ).resolves.toEqual({ id: 3 }); + + await expect( + repo.updateSource(1, { type: 'booklore', name: 'A', connectionConfig: {}, capabilities: {}, createdByUserId: 1 } as never), + ).resolves.toEqual({ + id: 10, + }); + await expect(repo.updateSourceValidation(1, { capabilities: {}, lastValidatedAt: null } as never)).resolves.toBeNull(); + await expect(repo.updateProfileScope(1, {})).resolves.toEqual({ id: 12 }); + await expect(repo.updatePlanArtifact(1, { plan: {}, summary: {} })).resolves.toEqual({ id: 14 }); + await expect(repo.updateRunState(1, 'failed', {})).resolves.toBeNull(); + + await expect(repo.findSourceById(100)).resolves.toEqual({ id: 100 }); + await expect(repo.findProfileById(200)).resolves.toEqual({ id: 200 }); + await expect(repo.findPlanArtifactById(300)).resolves.toEqual({ id: 300 }); + await expect(repo.findRunById(400)).resolves.toEqual({ id: 400 }); + }); + + it('lists sources/profiles/plans/runs and run metrics via select query chains', async () => { + const orderBy = vi.fn().mockResolvedValue([{ id: 1 }]); + const where = vi.fn().mockReturnValue({ orderBy }); + const from = vi.fn().mockReturnValue({ where, orderBy }); + const select = vi.fn().mockReturnValue({ from }); + const repo = new MigrationRepository({ select } as never); + + await expect(repo.listSources()).resolves.toEqual([{ id: 1 }]); + await expect(repo.listProfiles()).resolves.toEqual([{ id: 1 }]); + await expect(repo.listProfiles(5)).resolves.toEqual([{ id: 1 }]); + await expect(repo.listPlanArtifacts()).resolves.toEqual([{ id: 1 }]); + await expect(repo.listPlanArtifacts(5)).resolves.toEqual([{ id: 1 }]); + await expect(repo.listRuns()).resolves.toEqual([{ id: 1 }]); + await expect(repo.listRuns(5)).resolves.toEqual([{ id: 1 }]); + await expect(repo.listRunMetrics(5)).resolves.toEqual([{ id: 1 }]); + + expect(select).toHaveBeenCalledTimes(8); + }); + + it('builds title and metric lookups and supports empty-input short-circuits', async () => { + const limit = vi + .fn() + .mockResolvedValueOnce([{ id: 1 }]) + .mockResolvedValueOnce([]); + const where = vi + .fn() + .mockResolvedValueOnce([ + { bookId: 11, title: 'Dune' }, + { bookId: 12, title: null }, + ]) + .mockReturnValue({ limit }); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where: deleteWhere }); + const repo = new MigrationRepository({ select, delete: deleteFn } as never); + + await expect(repo.findBookTitlesByIds([])).resolves.toEqual(new Map()); + await expect(repo.findBookTitlesByIds([11, 12])).resolves.toEqual( + new Map([ + [11, 'Dune'], + [12, null], + ]), + ); + + await expect(repo.hasStageMetrics(7, 'user_state')).resolves.toBe(true); + await expect(repo.hasStageMetrics(7, 'user_state')).resolves.toBe(false); + await repo.clearStageMetrics(7, 'user_state'); + + expect(deleteFn).toHaveBeenCalledWith(schema.migrationRunMetrics); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/modules/migration/migration.service.test.ts b/server/src/modules/migration/migration.service.test.ts index 205e3670a..e9e4c0442 100644 --- a/server/src/modules/migration/migration.service.test.ts +++ b/server/src/modules/migration/migration.service.test.ts @@ -644,5 +644,269 @@ describe('MigrationService', () => { }), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('uses explicit duplicate match objects and parses plan user mappings', async () => { + const artifact = { + ...duplicatePlanArtifact, + plan: { + matchedBooks: [], + unresolvedBooks: [], + userMappings: [ + { sourceUserId: 'u1', targetUserId: 10 }, + { sourceUserId: '', targetUserId: 999 }, + ], + userPreview: [{ sourceUserId: 'u1', targetUserId: 10, username: 'u1', counts: {} }], + duplicateBookMatches: [ + { + targetBookId: 5, + sourceBookIds: ['sb10', 'sb11'], + strategies: ['isbn', 'title_author'], + reason: 'duplicate_target_match', + matches: [ + { sourceBookId: 'sb10', targetBookId: 5, strategy: 'isbn' }, + { sourceBookId: '', targetBookId: Number.NaN, strategy: 'file_hash' }, + { sourceBookId: 'sb11', targetBookId: 5, strategy: 'title_author' }, + ], + }, + ], + }, + sourceData: null, + }; + const updateFn = vi.fn((id: number, vals: { plan: unknown; summary: unknown }) => Promise.resolve({ ...artifact, id, ...vals })); + const service = createService({ + findPlanArtifactById: vi.fn(() => Promise.resolve(artifact)), + updatePlanArtifact: updateFn, + }); + + const result = await service.resolveDuplicateMatches(100, { + resolutions: [{ targetBookId: 5, selectedSourceBookId: 'sb11' }], + }); + + expect(result.summary?.status).toBe('ready_for_live_run'); + expect(updateFn).toHaveBeenCalledWith( + 100, + expect.objectContaining({ + summary: expect.objectContaining({ + mappedUsers: 1, + }), + }), + ); + }); + }); + + describe('createDryRunPlan', () => { + it('builds and persists a dry-run artifact summary', async () => { + const createPlanArtifact = vi.fn((values: Record) => + Promise.resolve({ + id: 900, + sourceId: values.sourceId, + profileId: values.profileId, + plan: values.plan, + summary: values.summary, + createdByUserId: values.createdByUserId, + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + const service = createService({ createPlanArtifact }); + const buildPlan = vi.fn(() => + Promise.resolve({ + plan: { + generatedAt: '2026-01-01T00:00:00.000Z', + snapshot: { books: 2 }, + matchedBooks: [{ sourceBookId: 'b1', targetBookId: 1, strategy: 'isbn' }], + unresolvedBooks: [{ sourceBookId: 'b2', reason: 'no_isbn_match' }], + duplicateBookMatches: [{ targetBookId: 77, sourceBookIds: ['b3', 'b4'], strategies: ['isbn', 'title_author'] }], + userPreview: [{ sourceUserId: 'u1', targetUserId: 10, username: 'reader', counts: { statuses: 1 } }], + }, + execution: { + sourceData: { + books: [{ sourceBookId: 'b1' }], + users: [{ sourceUserId: 'u1', username: 'reader', email: null, name: null }], + userBookStatuses: [], + userFileProgress: [], + bookmarks: [], + annotations: [], + shelves: [], + shelfBooks: [], + availableDomains: {}, + }, + }, + }), + ); + (service as any).planner = { buildPlan }; + + const result = await service.createDryRunPlan({ profileId: 2 }, 5); + + expect(buildPlan).toHaveBeenCalledWith( + expect.objectContaining({ + profile: expect.objectContaining({ id: 2 }), + source: expect.objectContaining({ id: 1, connectionConfig: {} }), + }), + ); + expect(createPlanArtifact).toHaveBeenCalledWith( + expect.objectContaining({ + sourceId: 1, + profileId: 2, + createdByUserId: 5, + summary: expect.objectContaining({ + status: 'blocked', + matchedBooks: 1, + unresolvedBooks: 1, + duplicateBookMatches: 1, + unresolvedByReason: { no_isbn_match: 1 }, + artifactSizeBytes: expect.objectContaining({ + plan: expect.any(Number), + sourceData: expect.any(Number), + total: expect.any(Number), + }), + }), + sourceSnapshotHash: expect.any(String), + profileHash: expect.any(String), + planHash: expect.any(String), + }), + ); + expect(result.id).toBe(900); + }); + + it('throws NotFoundException when profile or source is missing', async () => { + const missingProfileService = createService({ + findProfileById: vi.fn(() => Promise.resolve(null)), + }); + await expect(missingProfileService.createDryRunPlan({ profileId: 999 }, 1)).rejects.toBeInstanceOf(NotFoundException); + + const missingSourceService = createService({ + findProfileById: vi.fn(() => + Promise.resolve({ + id: 2, + sourceId: 1, + name: 'Profile', + version: 1, + userMappings: [{ sourceUserId: 'src-user', targetUserId: 10 }], + pathMappings: [], + scope: {}, + createdByUserId: 1, + createdAt: new Date(), + updatedAt: new Date(), + }), + ), + findSourceById: vi.fn(() => Promise.resolve(null)), + }); + await expect(missingSourceService.createDryRunPlan({ profileId: 2 }, 1)).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('workflow and reporting', () => { + it('delegates progress/report export methods to reporting service', () => { + const getRunProgress = vi.fn(() => ({ stage: 'init' })); + const getRunReport = vi.fn(() => ({ summary: {} })); + const exportRunReport = vi.fn(() => 'csv-data'); + const service = createService(); + (service as any).reporting = { getRunProgress, getRunReport, exportRunReport }; + + expect(service.getRunProgress(7)).toEqual({ stage: 'init' }); + expect(service.getRunReport(7)).toEqual({ summary: {} }); + expect(service.exportRunReport(7, 'csv')).toBe('csv-data'); + expect(getRunProgress).toHaveBeenCalledWith(7); + expect(getRunReport).toHaveBeenCalledWith(7); + expect(exportRunReport).toHaveBeenCalledWith(7, 'csv'); + }); + + it('returns active workflow bundle and hasActiveRun=true when an active run exists', async () => { + const now = new Date(); + const service = createService({ + listSources: vi.fn(() => + Promise.resolve([ + { + id: 1, + type: 'booklore', + name: 'Source 1', + connectionConfig: { host: 'a', user: 'u', password: 'pw', database: 'd' }, + capabilities: null, + lastValidatedAt: now, + createdByUserId: 1, + createdAt: now, + updatedAt: now, + }, + ]), + ), + listProfiles: vi.fn(() => + Promise.resolve([ + { + id: 2, + sourceId: 1, + name: 'Profile', + version: 1, + userMappings: [], + pathMappings: [], + scope: {}, + createdByUserId: 1, + createdAt: now, + updatedAt: now, + }, + ]), + ), + listPlanArtifacts: vi.fn(() => + Promise.resolve([ + { + id: 3, + sourceId: 1, + profileId: 2, + sourceSnapshotHash: 'x', + profileHash: 'y', + planHash: 'z', + plan: {}, + sourceData: {}, + summary: {}, + createdByUserId: 1, + createdAt: now, + updatedAt: now, + }, + ]), + ), + listRuns: vi.fn(() => + Promise.resolve([ + { + id: 4, + sourceId: 1, + profileId: 2, + planArtifactId: 3, + targetKey: 'projectx', + state: 'running', + currentStage: 'init', + triggeredByUserId: 1, + startedAt: now, + endedAt: null, + errorMessage: null, + createdAt: now, + updatedAt: now, + }, + ]), + ), + }); + + const state = await service.getWorkflowState(); + + expect(state.hasActiveRun).toBe(true); + expect(state.active?.source.id).toBe(1); + expect((state.active?.source.connectionConfig as Record).password).toBe('********'); + expect(state.active?.profile?.id).toBe(2); + expect(state.active?.plan?.id).toBe(3); + expect(state.active?.run?.id).toBe(4); + }); + + it('returns empty workflow state when there are no sources and no active runs', async () => { + const service = createService({ + listSources: vi.fn(() => Promise.resolve([])), + listProfiles: vi.fn(() => Promise.resolve([])), + listPlanArtifacts: vi.fn(() => Promise.resolve([])), + listRuns: vi.fn(() => Promise.resolve([])), + }); + + await expect(service.getWorkflowState()).resolves.toEqual({ + active: null, + hasActiveRun: false, + }); + }); }); }); diff --git a/server/src/modules/migration/planner/matching.service.test.ts b/server/src/modules/migration/planner/matching.service.test.ts index cb3daf564..e639cdaa6 100644 --- a/server/src/modules/migration/planner/matching.service.test.ts +++ b/server/src/modules/migration/planner/matching.service.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import type { SourceBook } from '../adapters/source-adapter.types'; +import { describe, expect, it, vi } from 'vitest'; -import { applyPathMappings, deriveUnresolvedReason } from './matching.service'; +import { applyPathMappings, deriveUnresolvedReason, MatchingService } from './matching.service'; describe('applyPathMappings', () => { it('uses the most specific source prefix when multiple mappings match', () => { @@ -55,3 +56,134 @@ describe('deriveUnresolvedReason', () => { expect(deriveUnresolvedReason(['title_author'])).toBe('no_title_author_match'); }); }); + +function sourceBook(overrides: Partial): SourceBook { + return { + sourceBookId: 'source-1', + title: null, + author: null, + subtitle: null, + isbn10: null, + isbn13: null, + description: null, + publisher: null, + publishedYear: null, + language: null, + filePath: null, + fileHash: null, + genres: [], + tags: [], + ...overrides, + }; +} + +describe('MatchingService.matchBooks', () => { + it('prioritizes match strategies and reports unresolved reasons with cache-aware lookups', async () => { + const service = new MatchingService({} as never); + + vi.spyOn(service as never, 'batchLookupIsbns').mockResolvedValue( + new Map([ + ['9781111111111', { kind: 'found', bookId: 101 }], + ['9782222222222', { kind: 'ambiguous' }], + ]), + ); + vi.spyOn(service as never, 'batchLookupFileHashes').mockResolvedValue( + new Map([ + ['hash-hit', { kind: 'found', bookId: 202 }], + ['hash-amb', { kind: 'ambiguous' }], + ]), + ); + + const lookupByFilePath = vi.spyOn(service as never, 'lookupByFilePath').mockImplementation((path: string) => { + if (path === '/target/matched.epub') return Promise.resolve({ kind: 'found', bookId: 303 }); + if (path === '/target/ambiguous.epub') return Promise.resolve({ kind: 'ambiguous' }); + return Promise.resolve({ kind: 'none' }); + }); + + const lookupByTitleAuthor = vi.spyOn(service as never, 'lookupByTitleAuthor').mockImplementation((title: string) => { + if (title === 'Title Match') return Promise.resolve({ kind: 'found', bookId: 404 }); + return Promise.resolve({ kind: 'none' }); + }); + + const result = await service.matchBooks( + [ + sourceBook({ sourceBookId: 'isbn', isbn13: '9781111111111' }), + sourceBook({ sourceBookId: 'hash', isbn13: '9782222222222', fileHash: 'hash-hit' }), + sourceBook({ sourceBookId: 'path', filePath: '/source/matched.epub' }), + sourceBook({ sourceBookId: 'path-cache', filePath: '/source/matched.epub' }), + sourceBook({ sourceBookId: 'title', title: 'Title Match', author: 'Frank Herbert' }), + sourceBook({ sourceBookId: 'ambiguous-path', filePath: '/source/ambiguous.epub', fileHash: 'hash-amb' }), + sourceBook({ sourceBookId: 'insufficient' }), + ], + [{ sourcePrefix: '/source', targetPrefix: '/target' }], + ); + + expect(result.matches).toEqual([ + { sourceBookId: 'isbn', targetBookId: 101, strategy: 'isbn' }, + { sourceBookId: 'hash', targetBookId: 202, strategy: 'file_hash' }, + { sourceBookId: 'path', targetBookId: 303, strategy: 'path_mapping' }, + { sourceBookId: 'path-cache', targetBookId: 303, strategy: 'path_mapping' }, + { sourceBookId: 'title', targetBookId: 404, strategy: 'title_author' }, + ]); + + expect(result.unresolved).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceBookId: 'ambiguous-path', + reason: 'ambiguous_file_hash_match', + }), + expect.objectContaining({ + sourceBookId: 'insufficient', + reason: 'insufficient_source_data', + }), + ]), + ); + + expect(lookupByFilePath).toHaveBeenCalledTimes(2); + expect(lookupByTitleAuthor).toHaveBeenCalledTimes(1); + }); +}); + +describe('MatchingService private lookups', () => { + it('batchLookupIsbns resolves unique, ambiguous, and missing matches', async () => { + const where13 = vi.fn().mockResolvedValue([ + { bookId: 1, isbn13: '9780441013593' }, + { bookId: 2, isbn13: '9780000000000' }, + { bookId: 3, isbn13: '9780000000000' }, + ]); + const where10 = vi.fn().mockResolvedValue([{ bookId: 1, isbn10: '0441013597' }]); + const from13 = vi.fn().mockReturnValue({ where: where13 }); + const from10 = vi.fn().mockReturnValue({ where: where10 }); + const select = vi.fn().mockReturnValueOnce({ from: from13 }).mockReturnValueOnce({ from: from10 }); + + const service = new MatchingService({ select } as never); + const lookup = await (service as never).batchLookupIsbns([ + sourceBook({ sourceBookId: 'a', isbn13: '9780441013593', isbn10: '0441013597' }), + sourceBook({ sourceBookId: 'b', isbn13: '9780000000000' }), + sourceBook({ sourceBookId: 'c', isbn13: '9789999999999' }), + ]); + + expect(lookup.get('9780441013593')).toEqual({ kind: 'found', bookId: 1 }); + expect(lookup.get('9780000000000')).toEqual({ kind: 'ambiguous' }); + expect(lookup.get('9789999999999')).toEqual({ kind: 'none' }); + }); + + it('lookupByTitleAuthor falls back from exact to approximate author matching', async () => { + const exactLimit = vi.fn().mockResolvedValue([]); + const exactWhere = vi.fn().mockReturnValue({ limit: exactLimit }); + const exactInnerJoin2 = vi.fn().mockReturnValue({ where: exactWhere }); + const exactInnerJoin1 = vi.fn().mockReturnValue({ innerJoin: exactInnerJoin2 }); + const exactFrom = vi.fn().mockReturnValue({ innerJoin: exactInnerJoin1 }); + + const approxLimit = vi.fn().mockResolvedValue([{ bookId: 777 }]); + const approxWhere = vi.fn().mockReturnValue({ limit: approxLimit }); + const approxInnerJoin2 = vi.fn().mockReturnValue({ where: approxWhere }); + const approxInnerJoin1 = vi.fn().mockReturnValue({ innerJoin: approxInnerJoin2 }); + const approxFrom = vi.fn().mockReturnValue({ innerJoin: approxInnerJoin1 }); + + const selectDistinct = vi.fn().mockReturnValueOnce({ from: exactFrom }).mockReturnValueOnce({ from: approxFrom }); + const service = new MatchingService({ selectDistinct } as never); + + await expect((service as never).lookupByTitleAuthor('Dune', ['Frank Herbert'])).resolves.toEqual({ kind: 'found', bookId: 777 }); + }); +}); diff --git a/server/src/modules/migration/planner/planner.service.test.ts b/server/src/modules/migration/planner/planner.service.test.ts new file mode 100644 index 000000000..e2ae08c2b --- /dev/null +++ b/server/src/modules/migration/planner/planner.service.test.ts @@ -0,0 +1,222 @@ +import { MigrationPlannerService, buildUserPreview } from './planner.service'; + +function makeSourceBook(id: string, title: string) { + return { + sourceBookId: id, + title, + author: 'Author', + subtitle: null, + isbn10: null, + isbn13: null, + description: null, + publisher: null, + publishedYear: null, + language: null, + filePath: `/src/${id}.epub`, + fileHash: null, + genres: [], + tags: [], + }; +} + +describe('MigrationPlannerService', () => { + it('builds plan with normalized mappings, split duplicates, and merged scope', async () => { + const sourceData = { + users: [ + { sourceUserId: 'u1', username: 'user-one', name: null, email: null }, + { sourceUserId: 'u2', username: 'user-two', name: null, email: null }, + ], + books: [makeSourceBook('s1', 'Book One'), makeSourceBook('s2', 'Book Two'), makeSourceBook('s3', 'Book Three')], + userBookStatuses: [ + { sourceUserId: 'u1', sourceBookId: 's3', status: 'reading', percentage: 42, startedAt: null, finishedAt: null, updatedAt: null }, + ], + userFileProgress: [ + { sourceUserId: 'u1', sourceBookId: 's3', percentage: 10, cfi: null, pageNumber: null, positionSeconds: 5, updatedAt: null }, + ], + bookmarks: [{ sourceUserId: 'u1', sourceBookId: 's3', title: null, cfi: null, positionSeconds: null, createdAt: null }], + annotations: [ + { + sourceUserId: 'u1', + sourceBookId: 's3', + cfi: 'epubcfi(/6/2)', + text: 'annotation', + color: null, + style: null, + note: null, + chapterTitle: null, + createdAt: null, + updatedAt: null, + }, + ], + shelves: [{ sourceShelfId: 'sh-1', sourceUserId: 'u1', name: 'Favorites' }], + shelfBooks: [{ sourceShelfId: 'sh-1', sourceUserId: '', sourceBookId: 's3' }], + availableDomains: { + metadata: true, + authors: true, + narrators: true, + genres: true, + tags: true, + userBookStatuses: true, + readingProgress: true, + bookmarks: true, + annotations: true, + shelves: true, + covers: true, + }, + }; + + const adapter = { + type: 'booklore', + validate: vi.fn(), + snapshot: vi.fn().mockResolvedValue({ + generatedAt: '2026-01-01T00:00:00.000Z', + sourceType: 'booklore', + sourceVersion: '1.0.0', + counts: { books: 3 }, + }), + exportData: vi.fn().mockResolvedValue(sourceData), + }; + + const adapterRegistry = { + get: vi.fn().mockReturnValue(adapter), + }; + const matchingService = { + matchBooks: vi.fn().mockResolvedValue({ + matches: [ + { sourceBookId: 's1', targetBookId: 100, strategy: 'isbn' }, + { sourceBookId: 's2', targetBookId: 100, strategy: 'path_mapping' }, + { sourceBookId: 's3', targetBookId: 101, strategy: 'title_author' }, + ], + unresolved: [{ sourceBookId: 'sx', title: 'Missing', reason: 'unmatched' }], + }), + }; + + const service = new MigrationPlannerService(adapterRegistry as never, matchingService as never); + + const result = await service.buildPlan({ + source: { + type: 'booklore', + connectionConfig: { + host: 'localhost', + user: 'root', + password: '', + database: 'booklore', + }, + } as never, + profile: { + userMappings: [ + { sourceUserId: 'u1', targetUserId: 501 }, + { source_user_id: 'u2', target_user_id: 502 }, + { sourceUserId: '', targetUserId: 503 }, + ], + pathMappings: [ + { sourcePrefix: '/src', targetPrefix: '/library' }, + { source_prefix: '/legacy', target_prefix: '/new-library' }, + { sourcePrefix: '', targetPrefix: '/ignored' }, + ], + scope: { metadata: true, tags: true }, + } as never, + scopeOverride: { tags: false, readingProgress: true }, + }); + + expect(adapterRegistry.get).toHaveBeenCalledWith('booklore'); + expect(matchingService.matchBooks).toHaveBeenCalledWith(sourceData.books, [ + { sourcePrefix: '/src', targetPrefix: '/library' }, + { sourcePrefix: '/legacy', targetPrefix: '/new-library' }, + ]); + + expect(result.plan.userMappings).toEqual([ + { sourceUserId: 'u1', targetUserId: 501 }, + { sourceUserId: 'u2', targetUserId: 502 }, + ]); + expect(result.plan.pathMappings).toEqual([ + { sourcePrefix: '/src', targetPrefix: '/library' }, + { sourcePrefix: '/legacy', targetPrefix: '/new-library' }, + ]); + expect(result.plan.scope).toEqual({ metadata: true, tags: false, readingProgress: true }); + + expect(result.execution.matchedBooks).toEqual([{ sourceBookId: 's3', targetBookId: 101, strategy: 'title_author' }]); + expect(result.execution.duplicateBookMatches).toEqual([ + expect.objectContaining({ + targetBookId: 100, + sourceBookIds: ['s1', 's2'], + }), + ]); + + expect(result.plan.userPreview).toEqual([ + { + sourceUserId: 'u1', + targetUserId: 501, + username: 'user-one', + counts: { + statuses: 1, + fileProgress: 1, + bookmarks: 1, + annotations: 1, + shelves: 1, + }, + }, + { + sourceUserId: 'u2', + targetUserId: 502, + username: 'user-two', + counts: { + statuses: 0, + fileProgress: 0, + bookmarks: 0, + annotations: 0, + shelves: 0, + }, + }, + ]); + }); + + it('buildUserPreview counts only mapped users and matched books', () => { + const preview = buildUserPreview( + { + users: [{ sourceUserId: 'u1', username: 'alice', name: null, email: null }], + books: [makeSourceBook('b1', 'Book One')], + userBookStatuses: [ + { sourceUserId: 'u1', sourceBookId: 'b1', status: 'read', percentage: 100, startedAt: null, finishedAt: null, updatedAt: null }, + ], + userFileProgress: [ + { sourceUserId: 'u1', sourceBookId: 'b1', percentage: 100, cfi: null, pageNumber: null, positionSeconds: null, updatedAt: null }, + ], + bookmarks: [{ sourceUserId: 'u1', sourceBookId: 'b1', title: null, cfi: null, positionSeconds: null, createdAt: null }], + annotations: [ + { + sourceUserId: 'u1', + sourceBookId: 'b1', + cfi: 'cfi', + text: 'note', + color: null, + style: null, + note: null, + chapterTitle: null, + createdAt: null, + updatedAt: null, + }, + ], + shelves: [{ sourceShelfId: 'shelf-1', sourceUserId: 'u1', name: 'Shelf' }], + shelfBooks: [{ sourceShelfId: 'shelf-1', sourceUserId: '', sourceBookId: 'b1' }], + } as never, + new Map([['u1', 99]]), + new Set(['b1']), + ); + + expect(preview).toEqual([ + { + sourceUserId: 'u1', + targetUserId: 99, + username: 'alice', + counts: { + statuses: 1, + fileProgress: 1, + bookmarks: 1, + annotations: 1, + shelves: 1, + }, + }, + ]); + }); +}); diff --git a/server/src/modules/migration/reporting/migration-reporting.service.test.ts b/server/src/modules/migration/reporting/migration-reporting.service.test.ts new file mode 100644 index 000000000..67bd21f1a --- /dev/null +++ b/server/src/modules/migration/reporting/migration-reporting.service.test.ts @@ -0,0 +1,213 @@ +import { NotFoundException } from '@nestjs/common'; + +import { MigrationReportingService } from './migration-reporting.service'; + +function makeRun(overrides?: Record) { + return { + id: 7, + sourceId: 1, + profileId: 2, + planArtifactId: 99, + state: 'running', + currentStage: 'user_state', + targetKey: 'default', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + errorMessage: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + ...overrides, + }; +} + +function makeService() { + const repo = { + findRunById: vi.fn(), + listRunMetrics: vi.fn(), + findPlanArtifactById: vi.fn(), + findBookTitlesByIds: vi.fn(), + }; + + return { + service: new MigrationReportingService(repo as never), + repo, + }; +} + +describe('MigrationReportingService', () => { + it('throws NotFoundException when run does not exist', async () => { + const { service, repo } = makeService(); + repo.findRunById.mockResolvedValue(null); + + await expect(service.getRunProgress(123)).rejects.toBeInstanceOf(NotFoundException); + await expect(service.getRunReport(123)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('aggregates run progress metrics', async () => { + const { service, repo } = makeService(); + repo.findRunById.mockResolvedValue(makeRun()); + repo.listRunMetrics.mockResolvedValue([ + { processed: 3, imported: 2, skipped: 1, unresolved: 0, failed: 0 }, + { processed: 4, imported: 3, skipped: 0, unresolved: 1, failed: 1 }, + ]); + + const result = await service.getRunProgress(7); + + expect(result.totals).toEqual({ + processed: 7, + imported: 5, + skipped: 1, + unresolved: 1, + failed: 1, + }); + }); + + it('builds detailed report view with normalized plan details and resolved titles', async () => { + const { service, repo } = makeService(); + repo.findRunById.mockResolvedValue(makeRun()); + repo.listRunMetrics.mockResolvedValue([ + { + stage: 'user_state', + entityType: 'reading_progress', + processed: 5, + imported: 4, + skipped: 0, + unresolved: 1, + failed: 0, + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ]); + repo.findPlanArtifactById.mockResolvedValue({ + sourceData: { + books: [ + { + sourceBookId: 's1', + title: 'Source One', + author: 'Author One', + }, + ], + }, + plan: { + matchedBooks: [{ sourceBookId: 's1', targetBookId: 100, strategy: 'isbn' }], + unresolvedBooks: [{ sourceBookId: 'sx', reason: 'unresolved_title' }], + duplicateBookMatches: [{ targetBookId: 101, sourceBookIds: ['s1', 's2'], strategies: ['isbn', 'path_mapping'] }], + }, + summary: { + perUserPreview: [ + { + sourceUserId: 'u1', + targetUserId: 9, + username: 'alice', + counts: { statuses: 1, fileProgress: 2, bookmarks: 3, annotations: 4, shelves: 5 }, + }, + ], + }, + }); + repo.findBookTitlesByIds.mockResolvedValue( + new Map([ + [100, 'Target One'], + [101, 'Target Two'], + ]), + ); + + const result = await service.getRunReport(7); + + expect(result.totals).toEqual({ + processed: 5, + imported: 4, + skipped: 0, + unresolved: 1, + failed: 0, + }); + expect(result.details.matchedBooks).toEqual([ + { + sourceBookId: 's1', + sourceTitle: 'Source One', + sourceAuthor: 'Author One', + targetBookId: 100, + targetTitle: 'Target One', + strategy: 'isbn', + }, + ]); + expect(result.details.unresolvedBooks).toEqual([ + { + sourceBookId: 'sx', + title: null, + author: null, + reason: 'unresolved_title', + }, + ]); + expect(result.details.duplicateBookMatches).toEqual([ + { + targetBookId: 101, + targetTitle: 'Target Two', + sourceBookIds: ['s1', 's2'], + sourceTitles: ['Source One', null], + strategies: ['isbn', 'path_mapping'], + reason: 'duplicate_target_match', + }, + ]); + expect(result.details.userPreview).toEqual([ + { + sourceUserId: 'u1', + targetUserId: 9, + username: 'alice', + counts: { statuses: 1, fileProgress: 2, bookmarks: 3, annotations: 4, shelves: 5 }, + }, + ]); + }); + + it('exports JSON report format by default', async () => { + const { service } = makeService(); + const report = { + run: makeRun(), + totals: { processed: 1, imported: 1, skipped: 0, unresolved: 0, failed: 0 }, + metrics: [], + plan: {}, + summary: {}, + details: { matchedBooks: [], unresolvedBooks: [], duplicateBookMatches: [], userPreview: [] }, + }; + vi.spyOn(service, 'getRunReport').mockResolvedValue(report as never); + + const result = await service.exportRunReport(7); + + expect(result.format).toBe('json'); + expect(result.contentType).toBe('application/json'); + expect(result.fileName).toBe('migration-run-7-report.json'); + expect(result.content).toContain('"processed": 1'); + }); + + it('exports CSV report and escapes commas/quotes', async () => { + const { service } = makeService(); + vi.spyOn(service, 'getRunReport').mockResolvedValue({ + run: makeRun(), + totals: { processed: 1, imported: 1, skipped: 0, unresolved: 0, failed: 0 }, + metrics: [], + plan: {}, + summary: {}, + details: { + matchedBooks: [ + { + sourceBookId: 's1', + sourceTitle: 'Title, "Quoted"', + sourceAuthor: 'Author', + targetBookId: 10, + targetTitle: 'Target', + strategy: 'isbn', + }, + ], + unresolvedBooks: [], + duplicateBookMatches: [], + userPreview: [], + }, + } as never); + + const result = await service.exportRunReport(7, 'csv'); + + expect(result.format).toBe('csv'); + expect(result.contentType).toBe('text/csv; charset=utf-8'); + expect(result.fileName).toBe('migration-run-7-report.csv'); + expect(result.content).toContain('section,stage,entityType'); + expect(result.content).toContain('"Title, ""Quoted"""'); + }); +}); diff --git a/server/src/modules/narrator/narrator.repository.test.ts b/server/src/modules/narrator/narrator.repository.test.ts new file mode 100644 index 000000000..83ffbd5e6 --- /dev/null +++ b/server/src/modules/narrator/narrator.repository.test.ts @@ -0,0 +1,112 @@ +import { bookNarrators, narrators } from '../../db/schema'; +import { NarratorRepository } from './narrator.repository'; + +function makeExecutor() { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn().mockReturnValue({ where: deleteWhere }); + + const narratorReturning = vi.fn(); + const narratorOnConflictDoUpdate = vi.fn().mockReturnValue({ returning: narratorReturning }); + const narratorValues = vi.fn().mockReturnValue({ onConflictDoUpdate: narratorOnConflictDoUpdate }); + + const linkOnConflictDoNothing = vi.fn().mockResolvedValue(undefined); + const linkValues = vi.fn().mockReturnValue({ onConflictDoNothing: linkOnConflictDoNothing }); + + const insertFn = vi.fn((table: unknown) => { + if (table === narrators) { + return { values: narratorValues }; + } + if (table === bookNarrators) { + return { values: linkValues }; + } + throw new Error('unexpected table passed to insert'); + }); + + return { + executor: { + delete: deleteFn, + insert: insertFn, + }, + deleteWhere, + narratorValues, + narratorReturning, + linkValues, + }; +} + +function makeRepository() { + const inner = makeExecutor(); + const transaction = vi.fn(async (callback: (tx: unknown) => Promise) => callback(inner.executor)); + const db = { + transaction, + ...inner.executor, + }; + + return { + repo: new NarratorRepository(db as never), + db, + transaction, + ...inner, + }; +} + +describe('NarratorRepository', () => { + it('replaces narrators in a transaction and preserves original display order', async () => { + const { repo, transaction, narratorReturning, narratorValues, linkValues } = makeRepository(); + narratorReturning.mockResolvedValue([ + { id: 11, name: 'Narrator A' }, + { id: 22, name: 'Narrator B' }, + ]); + + await repo.replaceForBook(91, [ + { name: 'Narrator A', sortName: null }, + { name: 'Narrator B', sortName: 'B, Narrator' }, + { name: 'Narrator A', sortName: 'ignored duplicate' }, + ]); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(narratorValues).toHaveBeenCalledWith([ + { name: 'Narrator A', sortName: null }, + { name: 'Narrator B', sortName: 'B, Narrator' }, + ]); + expect(linkValues).toHaveBeenCalledWith([ + { bookId: 91, narratorId: 11, displayOrder: 0 }, + { bookId: 91, narratorId: 22, displayOrder: 1 }, + { bookId: 91, narratorId: 11, displayOrder: 2 }, + ]); + }); + + it('uses explicit mutation executor without opening a transaction', async () => { + const { repo, transaction } = makeRepository(); + const external = makeExecutor(); + external.narratorReturning.mockResolvedValue([{ id: 33, name: 'Narrator C' }]); + + await repo.replaceForBook(7, [{ name: 'Narrator C', sortName: null }], external.executor as never); + + expect(transaction).not.toHaveBeenCalled(); + expect(external.narratorValues).toHaveBeenCalledWith([{ name: 'Narrator C', sortName: null }]); + expect(external.linkValues).toHaveBeenCalledWith([{ bookId: 7, narratorId: 33, displayOrder: 0 }]); + }); + + it('only deletes existing links when narrator list is empty', async () => { + const { repo, deleteWhere, narratorValues, linkValues } = makeRepository(); + + await repo.replaceForBook(55, []); + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(narratorValues).not.toHaveBeenCalled(); + expect(linkValues).not.toHaveBeenCalled(); + }); + + it('skips unresolved narrator names that are not returned by upsert', async () => { + const { repo, narratorReturning, linkValues } = makeRepository(); + narratorReturning.mockResolvedValue([{ id: 11, name: 'Narrator A' }]); + + await repo.replaceForBook(42, [ + { name: 'Narrator A', sortName: null }, + { name: 'Narrator B', sortName: null }, + ]); + + expect(linkValues).toHaveBeenCalledWith([{ bookId: 42, narratorId: 11, displayOrder: 0 }]); + }); +}); diff --git a/server/src/modules/opds/__tests__/opds-book.service.test.ts b/server/src/modules/opds/__tests__/opds-book.service.test.ts index 1c9a1d1dc..eb281fb17 100644 --- a/server/src/modules/opds/__tests__/opds-book.service.test.ts +++ b/server/src/modules/opds/__tests__/opds-book.service.test.ts @@ -1,43 +1,253 @@ -vi.mock('drizzle-orm', () => ({ - and: (...args: unknown[]) => ({ type: 'and', args }), - count: () => ({ type: 'count' }), - eq: (left: unknown, right: unknown) => ({ type: 'eq', left, right }), - inArray: (...args: unknown[]) => ({ type: 'inArray', args }), - sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ type: 'sql', strings, values }), -})); +import { ForbiddenException } from '@nestjs/common'; -import * as schema from '../../../db/schema'; import { OpdsBookService } from '../opds-book.service'; -function makeDbMock(whereSpy: vi.Mock) { +function makeChain(result: unknown, fields?: Record) { + const chain: Record = {}; + for (const key of Object.keys(fields ?? {})) { + chain[key] = { key }; + } + + const methods = ['from', 'leftJoin', 'innerJoin', 'where', 'groupBy', 'orderBy', 'limit', 'offset', '$dynamic', 'as'] as const; + for (const method of methods) { + chain[method] = vi.fn().mockReturnValue(chain); + } + + chain.then = (onFulfilled: (value: unknown) => unknown, onRejected?: (error: unknown) => unknown) => + Promise.resolve(result).then(onFulfilled, onRejected); + + return chain; +} + +function makeDb(selectQueue: unknown[] = []) { + const queue = [...selectQueue]; + return { - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - leftJoin: vi.fn().mockReturnValue({ - leftJoin: vi.fn().mockReturnValue({ - where: whereSpy, - }), - }), - }), - }), + select: vi.fn((fields?: Record) => makeChain(queue.shift() ?? [], fields)), }; } +function makeService(selectQueue: unknown[] = [], queryBuilderOverrides: Record = {}) { + const db = makeDb(selectQueue); + const queryBuilder = { + buildWhere: vi.fn().mockReturnValue(undefined), + ...queryBuilderOverrides, + }; + const service = new OpdsBookService(db as never, queryBuilder as never); + return { service, db, queryBuilder }; +} + describe('OpdsBookService', () => { - it('scopes file lookup by both fileId and bookId when fileId is provided', async () => { - const whereSpy = vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), + it('returns accessible library ids for superusers and regular users', async () => { + const superDb = makeDb([[{ id: 1 }, { id: 4 }]]); + const superService = new OpdsBookService(superDb as never, {} as never); + await expect(superService.getAccessibleLibraryIds(7, true)).resolves.toEqual([1, 4]); + + const userDb = makeDb([[{ libraryId: 2 }, { libraryId: 3 }]]); + const userService = new OpdsBookService(userDb as never, {} as never); + await expect(userService.getAccessibleLibraryIds(7, false)).resolves.toEqual([2, 3]); + }); + + it('handles getBooksPage access checks and lens delegation', async () => { + const { service } = makeService([[{ userId: 999 }], [{ userId: 7 }]]); + const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds'); + const lensSpy = vi.spyOn(service as never, 'getBooksByLens'); + const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery'); + + accessSpy.mockResolvedValueOnce([]); + await expect(service.getBooksPage(7, 'recent', 1, 50)).resolves.toEqual({ entries: [], total: 0 }); + + accessSpy.mockResolvedValueOnce([1]); + await expect(service.getBooksPage(7, 'recent', 1, 50, { libraryId: 2 })).rejects.toThrow(ForbiddenException); + + accessSpy.mockResolvedValueOnce([1]); + await expect(service.getBooksPage(7, 'recent', 1, 50, { collectionId: 11 })).rejects.toThrow(ForbiddenException); + + accessSpy.mockResolvedValueOnce([1, 2]); + lensSpy.mockResolvedValueOnce({ entries: [{ id: 5 }], total: 1 }); + await expect(service.getBooksPage(7, 'recent', 3, 25, { lensId: 4 })).resolves.toEqual({ entries: [{ id: 5 }], total: 1 }); + expect(lensSpy).toHaveBeenCalledWith(7, 4, [1, 2], 'recent', 3, 25); + + accessSpy.mockResolvedValueOnce([1, 2]); + paginatedSpy.mockResolvedValueOnce({ entries: [{ id: 9 }], total: 1 }); + await expect( + service.getBooksPage(7, 'title_asc', 2, 20, { + libraryId: 1, + collectionId: 10, + author: 'Frank Herbert', + series: 'Dune', + q: 'arrakis', + }), + ).resolves.toEqual({ entries: [{ id: 9 }], total: 1 }); + expect(paginatedSpy).toHaveBeenCalledTimes(1); + }); + + it('handles getRecentBooksPage empty-access and delegated paths', async () => { + const { service } = makeService(); + const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds'); + const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery'); + + accessSpy.mockResolvedValueOnce([]); + await expect(service.getRecentBooksPage(5, 1, 30)).resolves.toEqual({ entries: [], total: 0 }); + + accessSpy.mockResolvedValueOnce([2, 3]); + paginatedSpy.mockResolvedValueOnce({ entries: [{ id: 1 }], total: 1 }); + await expect(service.getRecentBooksPage(5, 2, 15)).resolves.toEqual({ entries: [{ id: 1 }], total: 1 }); + }); + + it('handles getRandomBooks guard branches and wrapped id selection', async () => { + const { service } = makeService([[{ minId: null, maxId: null }], [{ minId: 10, maxId: 12 }], [{ id: 11 }], [{ id: 10 }]]); + const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds'); + const fetchSpy = vi.spyOn(service as never, 'fetchBookEntries'); + + await expect(service.getRandomBooks(7, 0)).resolves.toEqual([]); + + accessSpy.mockResolvedValueOnce([]); + await expect(service.getRandomBooks(7, 2)).resolves.toEqual([]); + + accessSpy.mockResolvedValueOnce([1]); + await expect(service.getRandomBooks(7, 2)).resolves.toEqual([]); + + accessSpy.mockResolvedValueOnce([1]); + fetchSpy.mockResolvedValueOnce([{ id: 11 }, { id: 10 }]); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + await expect(service.getRandomBooks(7, 2)).resolves.toEqual([{ id: 11 }, { id: 10 }]); + expect(fetchSpy).toHaveBeenCalledWith([11, 10]); + }); + + it('returns distinct authors and series with and without access', async () => { + const { service } = makeService([[{ name: 'Frank Herbert', bookCount: 2 }], [{ name: 'Dune', bookCount: 2 }]]); + const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds'); + + accessSpy.mockResolvedValueOnce([]); + await expect(service.getDistinctAuthors(1)).resolves.toEqual([]); + + accessSpy.mockResolvedValueOnce([1]); + await expect(service.getDistinctAuthors(1)).resolves.toEqual([{ name: 'Frank Herbert', bookCount: 2 }]); + + accessSpy.mockResolvedValueOnce([1]); + await expect(service.getDistinctSeries(1)).resolves.toEqual([{ name: 'Dune', bookCount: 2 }]); + }); + + it('returns user collections and lenses', async () => { + const { service } = makeService([[{ id: 4, name: 'Favorites', bookCount: 1 }], [{ id: 7, name: 'Unread', icon: 'sparkles' }]]); + + await expect(service.getUserCollections(8)).resolves.toEqual([{ id: 4, name: 'Favorites', bookCount: 1 }]); + await expect(service.getUserLenses(8)).resolves.toEqual([{ id: 7, name: 'Unread', icon: 'sparkles' }]); + }); + + it('enforces validateBookAccess ownership checks', async () => { + const { service } = makeService([[{ libraryId: 3 }], [{ libraryId: 4 }]]); + const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds'); + + accessSpy.mockResolvedValueOnce([1, 2]); + await expect(service.validateBookAccess(5, 7)).rejects.toThrow(ForbiddenException); + + accessSpy.mockResolvedValueOnce([1, 4]); + await expect(service.validateBookAccess(5, 7)).resolves.toBeUndefined(); + }); + + it('resolves getBookFiles with fallback formatting and title values', async () => { + const { service } = makeService([[], [{ absolutePath: '/books/a.epub', format: null, title: null }], []]); + + await expect(service.getBookFiles(7, 42)).resolves.toBeNull(); + + await expect(service.getBookFiles(7)).resolves.toEqual({ + absolutePath: '/books/a.epub', + format: 'unknown', + title: 'book-7', + authorName: '', }); - const db = makeDbMock(whereSpy); - const service = new OpdsBookService(db as never, {} as never); + }); - await service.getBookFiles(7, 42); + it('returns no lens books when lens is missing or private to another user', async () => { + const { service } = makeService([[], [{ id: 5, userId: 99, isPublic: false, filter: null }]]); - const whereArg = whereSpy.mock.calls[0][0] as { type: string; args: Array<{ type: string; left: unknown; right: unknown }> }; - expect(whereArg.type).toBe('and'); - expect(whereArg.args).toEqual([ - { type: 'eq', left: schema.bookFiles.id, right: 42 }, - { type: 'eq', left: schema.bookFiles.bookId, right: 7 }, + await expect((service as never).getBooksByLens(7, 5, [1], 'recent', 1, 25)).resolves.toEqual({ entries: [], total: 0 }); + await expect((service as never).getBooksByLens(7, 5, [1], 'recent', 1, 25)).resolves.toEqual({ entries: [], total: 0 }); + }); + + it('builds lens filters and delegates lens pagination', async () => { + const { service, queryBuilder } = makeService([[{ id: 9, userId: 7, isPublic: false, filter: { op: 'and' } }]], { + buildWhere: vi.fn().mockReturnValue({ kind: 'where' }), + }); + const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery').mockResolvedValue({ entries: [{ id: 1 }], total: 1 }); + + await expect((service as never).getBooksByLens(7, 9, [1, 2], 'title_desc', 2, 10)).resolves.toEqual({ entries: [{ id: 1 }], total: 1 }); + expect(queryBuilder.buildWhere).toHaveBeenCalledWith({ op: 'and' }, { accessibleLibraryIds: [1, 2], userId: 7 }); + expect(paginatedSpy).toHaveBeenCalledTimes(1); + }); + + it('paginates ids and only fetches entries when rows are present', async () => { + const empty = makeService([[], [], [{ total: 5 }]]); + await expect((empty.service as never).paginatedBookQuery({ kind: 'where' }, 'recent', 2, 10)).resolves.toEqual({ entries: [], total: 5 }); + + const filled = makeService([[], [{ id: 3 }, { id: 1 }], [{ total: 2 }]]); + const fetchSpy = vi.spyOn(filled.service as never, 'fetchBookEntries').mockResolvedValue([{ id: 3 }, { id: 1 }]); + + await expect((filled.service as never).paginatedBookQuery({ kind: 'where' }, 'author_asc', 1, 25)).resolves.toEqual({ + entries: [{ id: 3 }, { id: 1 }], + total: 2, + }); + expect(fetchSpy).toHaveBeenCalledWith([3, 1]); + }); + + it('maps metadata, authors, and files into ordered OPDS entries', async () => { + const now = new Date('2026-04-15T00:00:00.000Z'); + const { service } = makeService([ + [ + { + id: 2, + folderPath: '/library/second', + addedAt: now, + bookUpdatedAt: now, + title: null, + description: null, + seriesName: null, + seriesIndex: null, + language: null, + publisher: null, + isbn13: null, + }, + { + id: 1, + folderPath: '/library/first', + addedAt: now, + bookUpdatedAt: now, + title: 'First', + description: 'Desc', + seriesName: 'Series', + seriesIndex: 1, + language: 'en', + publisher: 'Pub', + isbn13: '123', + }, + ], + [ + { bookId: 1, name: 'Author One' }, + { bookId: 2, name: 'Author Two' }, + ], + [ + { bookId: 1, id: 10, format: 'epub', role: 'primary' }, + { bookId: 2, id: 20, format: null, role: 'primary' }, + ], ]); + + await expect((service as never).fetchBookEntries([1, 2])).resolves.toEqual([ + expect.objectContaining({ + id: 1, + title: 'First', + authors: ['Author One'], + files: [{ id: 10, format: 'epub' }], + }), + expect.objectContaining({ + id: 2, + title: 'second', + authors: ['Author Two'], + files: [{ id: 20, format: 'unknown' }], + }), + ]); + + await expect((service as never).fetchBookEntries([])).resolves.toEqual([]); }); }); diff --git a/server/src/modules/opds/dto/opds-user.dto.test.ts b/server/src/modules/opds/dto/opds-user.dto.test.ts new file mode 100644 index 000000000..27bfa5c93 --- /dev/null +++ b/server/src/modules/opds/dto/opds-user.dto.test.ts @@ -0,0 +1,37 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateOpdsUserDto } from './create-opds-user.dto'; +import { UpdateOpdsUserDto } from './update-opds-user.dto'; + +describe('OPDS user DTOs', () => { + it('accepts valid create and update payloads', async () => { + const createDto = plainToInstance(CreateOpdsUserDto, { + username: 'reader-user', + password: 'password123', + sortOrder: 'author_asc', + }); + const updateDto = plainToInstance(UpdateOpdsUserDto, { + sortOrder: 'series_desc', + }); + + expect(await validate(createDto)).toEqual([]); + expect(await validate(updateDto)).toEqual([]); + }); + + it('rejects invalid create and update payloads', async () => { + const badCreate = plainToInstance(CreateOpdsUserDto, { + username: 'ab', + password: 'short', + sortOrder: 'invalid_order', + }); + const badUpdate = plainToInstance(UpdateOpdsUserDto, { + sortOrder: 'invalid_order', + }); + + expect((await validate(badCreate)).length).toBeGreaterThan(0); + expect((await validate(badUpdate)).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/opds/opds-user.controller.test.ts b/server/src/modules/opds/opds-user.controller.test.ts new file mode 100644 index 000000000..d8fa14385 --- /dev/null +++ b/server/src/modules/opds/opds-user.controller.test.ts @@ -0,0 +1,24 @@ +import { OpdsUserController } from './opds-user.controller'; + +describe('OpdsUserController', () => { + it('delegates CRUD operations to OpdsUserService with current user scoping', async () => { + const service = { + findAllForUser: vi.fn().mockResolvedValue('all'), + create: vi.fn().mockResolvedValue('created'), + update: vi.fn().mockResolvedValue('updated'), + delete: vi.fn().mockResolvedValue(undefined), + }; + const controller = new OpdsUserController(service as never); + const user = { id: 5 } as never; + + await expect(controller.findAll(user)).resolves.toBe('all'); + await expect(controller.create(user, { username: 'reader', password: 'password123' } as never)).resolves.toBe('created'); + await expect(controller.update(user, 11, { sortOrder: 'title_asc' } as never)).resolves.toBe('updated'); + await expect(controller.delete(user, 11)).resolves.toBeUndefined(); + + expect(service.findAllForUser).toHaveBeenCalledWith(5); + expect(service.create).toHaveBeenCalledWith(5, { username: 'reader', password: 'password123' }); + expect(service.update).toHaveBeenCalledWith(5, 11, { sortOrder: 'title_asc' }); + expect(service.delete).toHaveBeenCalledWith(5, 11); + }); +}); diff --git a/server/src/modules/opds/opds-user.decorator.test.ts b/server/src/modules/opds/opds-user.decorator.test.ts new file mode 100644 index 000000000..18134f457 --- /dev/null +++ b/server/src/modules/opds/opds-user.decorator.test.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; + +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import type { ExecutionContext } from '@nestjs/common'; + +import { OpdsUser } from './opds-user.decorator'; + +describe('OpdsUser decorator', () => { + it('extracts opdsUser from request context', () => { + class TestController { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + handler(@OpdsUser() _user: unknown) {} + } + + const metadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, TestController, 'handler') as Record< + string, + { factory: (data: unknown, ctx: ExecutionContext) => unknown } + >; + const factory = Object.values(metadata)[0]?.factory; + + const ctx = { + switchToHttp: () => ({ + getRequest: () => ({ + opdsUser: { opdsUserId: 3, userId: 9, username: 'reader' }, + }), + }), + } as unknown as ExecutionContext; + + expect(factory).toBeDefined(); + expect(factory(undefined, ctx)).toEqual({ + opdsUserId: 3, + userId: 9, + username: 'reader', + }); + }); +}); diff --git a/server/src/modules/opds/opds.controller.test.ts b/server/src/modules/opds/opds.controller.test.ts index a400ed61d..10ed2fa99 100644 --- a/server/src/modules/opds/opds.controller.test.ts +++ b/server/src/modules/opds/opds.controller.test.ts @@ -9,6 +9,7 @@ vi.mock('fs/promises', () => ({ import { createReadStream } from 'fs'; import { readdir, stat } from 'fs/promises'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import type { MockedFunction } from 'vitest'; import { OpdsController } from './opds.controller'; @@ -18,9 +19,35 @@ const mockReaddir = readdir as MockedFunction; const mockStat = stat as MockedFunction; function makeController() { - const opdsService = {} as never; + const opdsService = { + generateRootNavigation: vi.fn().mockReturnValue(''), + generateLibrariesNavigation: vi.fn().mockReturnValue(''), + generateCollectionsNavigation: vi.fn().mockReturnValue(''), + generateLensesNavigation: vi.fn().mockReturnValue(''), + generateAuthorsNavigation: vi.fn().mockReturnValue(''), + generateSeriesNavigation: vi.fn().mockReturnValue(''), + generateAcquisitionFeed: vi.fn().mockReturnValue(''), + generateOpenSearchDescription: vi.fn().mockReturnValue(''), + } as never; const opdsBookService = { + getAccessibleLibraries: vi.fn().mockResolvedValue([{ id: 1, name: 'Main', bookCount: 10 }]), + getUserCollections: vi.fn().mockResolvedValue([{ id: 4, name: 'Favorites', bookCount: 2 }]), + getUserLenses: vi.fn().mockResolvedValue([{ id: 7, name: 'Unread', icon: 'sparkles' }]), + getDistinctAuthors: vi.fn().mockResolvedValue([{ name: 'Frank Herbert', bookCount: 3 }]), + getDistinctSeries: vi.fn().mockResolvedValue([ + { name: null, bookCount: 1 }, + { name: 'Dune', bookCount: 2 }, + ]), + getBooksPage: vi.fn().mockResolvedValue({ entries: [{ id: 1 }], total: 1 }), + getRecentBooksPage: vi.fn().mockResolvedValue({ entries: [{ id: 2 }], total: 1 }), + getRandomBooks: vi.fn().mockResolvedValue([{ id: 3 }]), validateBookAccess: vi.fn().mockResolvedValue(undefined), + getBookFiles: vi.fn().mockResolvedValue({ + absolutePath: '/books/library/book.epub', + format: 'epub', + title: 'Book Title', + authorName: 'Author Name', + }), } as never; const config = { get: vi.fn().mockReturnValue('/books'), @@ -28,6 +55,7 @@ function makeController() { return { controller: new OpdsController(opdsService, opdsBookService, config), + opdsService, opdsBookService, }; } @@ -52,6 +80,113 @@ describe('OpdsController', () => { vi.resetAllMocks(); }); + it('renders root navigation feed', () => { + const { controller, opdsService } = makeController(); + const reply = makeReply(); + + controller.root({} as never, reply); + + expect(opdsService.generateRootNavigation).toHaveBeenCalledOnce(); + expect(reply.type).toHaveBeenCalledWith('application/atom+xml;profile=opds-catalog;kind=navigation; charset=utf-8'); + expect(reply.send).toHaveBeenCalledWith(''); + }); + + it('renders navigation endpoints with OPDS navigation mime type', async () => { + const { controller, opdsBookService, opdsService } = makeController(); + const user = { userId: 8, isSuperuser: false } as never; + + await controller.libraries(user, makeReply()); + await controller.collections(user, makeReply()); + await controller.lenses(user, makeReply()); + await controller.authors(user, makeReply()); + await controller.series(user, makeReply()); + + expect(opdsBookService.getAccessibleLibraries).toHaveBeenCalledWith(8, false); + expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(8); + expect(opdsBookService.getUserLenses).toHaveBeenCalledWith(8); + expect(opdsBookService.getDistinctAuthors).toHaveBeenCalledWith(8, false); + expect(opdsBookService.getDistinctSeries).toHaveBeenCalledWith(8, false); + expect(opdsService.generateSeriesNavigation).toHaveBeenCalledWith([{ name: 'Dune', bookCount: 2 }]); + }); + + it('catalog clamps pagination and passes parsed filters to the book service', async () => { + const { controller, opdsBookService, opdsService } = makeController(); + const reply = makeReply(); + const user = { userId: 7, isSuperuser: true, sortOrder: 'author_desc', coverToken: 'token' } as never; + + await controller.catalog(user, -4, 500, '2', '11', '15', 'Frank Herbert', 'Dune', 'arrakis', reply); + + expect(opdsBookService.getBooksPage).toHaveBeenCalledWith( + 7, + 'author_desc', + 1, + 100, + { + libraryId: 2, + collectionId: 11, + lensId: 15, + author: 'Frank Herbert', + series: 'Dune', + q: 'arrakis', + }, + true, + ); + expect(opdsService.generateAcquisitionFeed).toHaveBeenCalledWith( + 'Search: arrakis', + 'urn:projectx:catalog', + [{ id: 1 }], + 1, + 1, + 100, + expect.stringContaining('/api/v1/opds/catalog?'), + 'token', + ); + }); + + it('rejects invalid catalog filter ids and deep pagination windows', async () => { + const { controller } = makeController(); + const user = { userId: 7, isSuperuser: false, sortOrder: 'recent', coverToken: 'token' } as never; + const reply = makeReply(); + + await expect(controller.catalog(user, 1, 50, 'abc', undefined, undefined, undefined, undefined, undefined, reply)).rejects.toThrow( + BadRequestException, + ); + await expect(controller.catalog(user, 1_000_000, 100, undefined, undefined, undefined, undefined, undefined, undefined, reply)).rejects.toThrow( + BadRequestException, + ); + }); + + it('renders recent and surprise acquisition feeds', async () => { + const { controller, opdsBookService, opdsService } = makeController(); + const user = { userId: 12, isSuperuser: false, coverToken: 'cover-token' } as never; + + await controller.recent(user, 0, 1000, makeReply()); + await controller.surprise(user, makeReply()); + + expect(opdsBookService.getRecentBooksPage).toHaveBeenCalledWith(12, 1, 100, false); + expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(12, 25, false); + expect(opdsService.generateAcquisitionFeed).toHaveBeenCalledWith( + 'Random Books', + 'urn:projectx:surprise', + [{ id: 3 }], + 1, + 1, + 25, + '/api/v1/opds/surprise', + 'cover-token', + ); + }); + + it('returns OpenSearch description with OPDS search mime type', () => { + const { controller } = makeController(); + const reply = makeReply(); + + controller.searchDescription({} as never, reply); + + expect(reply.type).toHaveBeenCalledWith('application/opensearchdescription+xml; charset=utf-8'); + expect(reply.send).toHaveBeenCalledWith(''); + }); + it('serves the preferred stored cover file for OPDS clients', async () => { const { controller, opdsBookService } = makeController(); const reply = makeReply(); @@ -69,4 +204,89 @@ describe('OpdsController', () => { expect(reply.type).toHaveBeenCalledWith('image/png'); expect(reply.send).toHaveBeenCalledWith(stream); }); + + it('returns 304 when cover ETag matches If-None-Match', async () => { + const { controller } = makeController(); + const reply = makeReply(); + + mockReaddir.mockResolvedValue(['cover_custom.jpg'] as never); + mockStat.mockResolvedValue({ mtimeMs: 5000 } as never); + + await controller.cover(42, { userId: 7, isSuperuser: false } as never, reply, '"5000"'); + + expect(reply.status).toHaveBeenCalledWith(304); + expect(reply.send).toHaveBeenCalledWith(); + expect(mockCreateReadStream).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when no cover exists', async () => { + const { controller } = makeController(); + mockReaddir.mockRejectedValue(new Error('missing dir')); + + await expect(controller.cover(42, { userId: 7, isSuperuser: false } as never, makeReply())).rejects.toThrow(NotFoundException); + }); + + it('serves thumbnail image when available', async () => { + const { controller } = makeController(); + const reply = makeReply(); + const stream = { kind: 'thumbnail-stream' }; + + mockStat.mockResolvedValue({ mtimeMs: 2222 } as never); + mockCreateReadStream.mockReturnValue(stream as never); + + await controller.thumbnail(12, { userId: 1, isSuperuser: false } as never, reply); + + expect(reply.type).toHaveBeenCalledWith('image/jpeg'); + expect(reply.header).toHaveBeenCalledWith('ETag', '"2222"'); + expect(reply.send).toHaveBeenCalledWith(stream); + }); + + it('returns 304 for thumbnail when ETag matches', async () => { + const { controller } = makeController(); + const reply = makeReply(); + + mockStat.mockResolvedValue({ mtimeMs: 3333 } as never); + + await controller.thumbnail(12, { userId: 1, isSuperuser: false } as never, reply, '"3333"'); + + expect(reply.status).toHaveBeenCalledWith(304); + expect(reply.send).toHaveBeenCalledWith(); + expect(mockCreateReadStream).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when thumbnail file is missing', async () => { + const { controller } = makeController(); + mockStat.mockRejectedValue(new Error('missing thumbnail')); + + await expect(controller.thumbnail(42, { userId: 7, isSuperuser: false } as never, makeReply())).rejects.toThrow(NotFoundException); + }); + + it('downloads file with sanitized attachment name', async () => { + const { controller, opdsBookService } = makeController(); + const reply = makeReply(); + const stream = { kind: 'download-stream' }; + + opdsBookService.getBookFiles.mockResolvedValue({ + absolutePath: '/books/library/book.epub', + format: 'epub', + title: 'Bad:/Title*', + authorName: 'Auor', + }); + mockStat.mockResolvedValue({ size: 12345 } as never); + mockCreateReadStream.mockReturnValue(stream as never); + + await controller.download(99, 0, { userId: 2, isSuperuser: false } as never, reply); + + expect(reply.header).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="BadTitle - Author.epub"'); + expect(reply.header).toHaveBeenCalledWith('Content-Length', 12345); + expect(reply.type).toHaveBeenCalledWith('application/epub+zip'); + expect(reply.send).toHaveBeenCalledWith(stream); + }); + + it('throws NotFoundException when requested download file is unavailable', async () => { + const { controller, opdsBookService } = makeController(); + opdsBookService.getBookFiles.mockResolvedValue(null); + + await expect(controller.download(88, 77, { userId: 2, isSuperuser: false } as never, makeReply())).rejects.toThrow(NotFoundException); + }); }); diff --git a/server/src/modules/opds/opds.module.test.ts b/server/src/modules/opds/opds.module.test.ts new file mode 100644 index 000000000..98e5cdd8e --- /dev/null +++ b/server/src/modules/opds/opds.module.test.ts @@ -0,0 +1,30 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { CommonModule } from '../../common/common.module'; +import { AppSettingsModule } from '../app-settings/app-settings.module'; +import { BookModule } from '../book/book.module'; +import { UserModule } from '../user/user.module'; +import { OpdsAuthGuard } from './opds-auth.guard'; +import { OpdsBookService } from './opds-book.service'; +import { OpdsController } from './opds.controller'; +import { OpdsEnabledGuard } from './opds-enabled.guard'; +import { OpdsModule } from './opds.module'; +import { OpdsService } from './opds.service'; +import { OpdsUserController } from './opds-user.controller'; +import { OpdsUserService } from './opds-user.service'; + +describe('OpdsModule', () => { + it('registers expected module wiring', () => { + expect(Reflect.getMetadata(MODULE_METADATA.IMPORTS, OpdsModule)).toEqual([AppSettingsModule, BookModule, UserModule, CommonModule]); + expect(Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, OpdsModule)).toEqual([OpdsController, OpdsUserController]); + expect(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, OpdsModule)).toEqual([ + OpdsService, + OpdsBookService, + OpdsUserService, + OpdsAuthGuard, + OpdsEnabledGuard, + ]); + }); +}); diff --git a/server/src/modules/path/path.controller.test.ts b/server/src/modules/path/path.controller.test.ts new file mode 100644 index 000000000..bfe1d0b9c --- /dev/null +++ b/server/src/modules/path/path.controller.test.ts @@ -0,0 +1,25 @@ +import { PathController } from './path.controller'; + +describe('PathController', () => { + const pathService = { + listDirectories: vi.fn(), + }; + + const controller = new PathController(pathService as never); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('defaults to root path when query path is missing', async () => { + await controller.listDirectories(undefined as never); + + expect(pathService.listDirectories).toHaveBeenCalledWith('/'); + }); + + it('passes provided query path through to the service', async () => { + await controller.listDirectories('/books'); + + expect(pathService.listDirectories).toHaveBeenCalledWith('/books'); + }); +}); diff --git a/server/src/modules/path/path.module.test.ts b/server/src/modules/path/path.module.test.ts new file mode 100644 index 000000000..6998b8c1c --- /dev/null +++ b/server/src/modules/path/path.module.test.ts @@ -0,0 +1,12 @@ +import 'reflect-metadata'; + +import { PathController } from './path.controller'; +import { PathModule } from './path.module'; +import { PathService } from './path.service'; + +describe('PathModule', () => { + it('registers path controller and service', () => { + expect(Reflect.getMetadata('controllers', PathModule)).toEqual([PathController]); + expect(Reflect.getMetadata('providers', PathModule)).toEqual([PathService]); + }); +}); diff --git a/server/src/modules/path/path.service.test.ts b/server/src/modules/path/path.service.test.ts new file mode 100644 index 000000000..cd4734ab9 --- /dev/null +++ b/server/src/modules/path/path.service.test.ts @@ -0,0 +1,65 @@ +vi.mock('fs/promises', () => ({ + readdir: vi.fn(), + stat: vi.fn(), +})); + +import { readdir, stat } from 'fs/promises'; + +import { PathService } from './path.service'; + +const readdirMock = vi.mocked(readdir); +const statMock = vi.mocked(stat); + +function entry(name: string, options: { directory?: boolean; symbolicLink?: boolean } = {}) { + return { + name, + isDirectory: () => options.directory ?? false, + isSymbolicLink: () => options.symbolicLink ?? false, + }; +} + +describe('PathService', () => { + const service = new PathService(); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns empty for blocked system paths', async () => { + await expect(service.listDirectories('/proc/1')).resolves.toEqual([]); + await expect(service.listDirectories('/sys/class')).resolves.toEqual([]); + + expect(readdirMock).not.toHaveBeenCalled(); + }); + + it('lists only accessible non-hidden directories and sorts them by name', async () => { + readdirMock.mockResolvedValue([ + entry('notes.txt'), + entry('.cache', { directory: true }), + entry('zeta', { directory: true }), + entry('alpha-link', { symbolicLink: true }), + entry('beta', { directory: true }), + ] as never); + + statMock.mockImplementation((fullPath) => { + if (String(fullPath).endsWith('/beta')) { + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + } + + return Promise.resolve({ + isDirectory: () => true, + } as never); + }); + + await expect(service.listDirectories('/tmp/books')).resolves.toEqual([ + { name: 'alpha-link', path: '/tmp/books/alpha-link' }, + { name: 'zeta', path: '/tmp/books/zeta' }, + ]); + }); + + it('returns empty when reading the target directory fails', async () => { + readdirMock.mockRejectedValue(new Error('missing')); + + await expect(service.listDirectories('/does/not/exist')).resolves.toEqual([]); + }); +}); diff --git a/server/src/modules/reader/cbz/cbz.controller.test.ts b/server/src/modules/reader/cbz/cbz.controller.test.ts new file mode 100644 index 000000000..35ca14b22 --- /dev/null +++ b/server/src/modules/reader/cbz/cbz.controller.test.ts @@ -0,0 +1,44 @@ +import { PassThrough } from 'stream'; + +import { CbzController } from './cbz.controller'; + +describe('CbzController', () => { + const cbzService = { + getPageCount: vi.fn(), + streamPage: vi.fn(), + }; + + const controller = new CbzController(cbzService as any); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('wraps page count response from service', async () => { + const user = { id: 7, isSuperuser: false, permissions: [] } as any; + cbzService.getPageCount.mockResolvedValue(42); + + const result = await controller.getPageCount(12, user); + + expect(result).toEqual({ pageCount: 42 }); + expect(cbzService.getPageCount).toHaveBeenCalledWith(12, user); + }); + + it('sets response headers and streams page bytes', async () => { + const user = { id: 7, isSuperuser: false, permissions: [] } as any; + const stream = new PassThrough(); + const reply = { + header: vi.fn(), + type: vi.fn(), + send: vi.fn(), + }; + cbzService.streamPage.mockResolvedValue({ stream, mimeType: 'image/png' }); + + await controller.getPage(12, 3, user, reply as any); + + expect(cbzService.streamPage).toHaveBeenCalledWith(12, 3, user); + expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'public, max-age=31536000, immutable'); + expect(reply.type).toHaveBeenCalledWith('image/png'); + expect(reply.send).toHaveBeenCalledWith(stream); + }); +}); diff --git a/server/src/modules/reader/cbz/cbz.module.test.ts b/server/src/modules/reader/cbz/cbz.module.test.ts new file mode 100644 index 000000000..ea44db359 --- /dev/null +++ b/server/src/modules/reader/cbz/cbz.module.test.ts @@ -0,0 +1,16 @@ +import 'reflect-metadata'; + +vi.mock('../../book/book.module', () => ({ BookModule: class BookModule {} })); + +import { BookModule } from '../../book/book.module'; +import { CbzController } from './cbz.controller'; +import { CbzModule } from './cbz.module'; +import { CbzService } from './cbz.service'; + +describe('CbzModule', () => { + it('registers expected imports/controllers/providers', () => { + expect(Reflect.getMetadata('imports', CbzModule)).toEqual([BookModule]); + expect(Reflect.getMetadata('controllers', CbzModule)).toEqual([CbzController]); + expect(Reflect.getMetadata('providers', CbzModule)).toEqual([CbzService]); + }); +}); diff --git a/server/src/modules/reader/cbz/cbz.service.test.ts b/server/src/modules/reader/cbz/cbz.service.test.ts new file mode 100644 index 000000000..8c1955225 --- /dev/null +++ b/server/src/modules/reader/cbz/cbz.service.test.ts @@ -0,0 +1,260 @@ +vi.mock('fs/promises', () => ({ readFile: vi.fn() })); +vi.mock('fs', () => ({ createReadStream: vi.fn() })); +vi.mock('zlib', async () => { + const actual = await vi.importActual('zlib'); + return { ...actual, createInflateRaw: vi.fn() }; +}); +vi.mock('node-unrar-js', () => ({ createExtractorFromData: vi.fn() })); +vi.mock('../../../common/sevenzip', () => ({ getSevenZip: vi.fn() })); + +import { NotFoundException } from '@nestjs/common'; +import { createReadStream } from 'fs'; +import { readFile } from 'fs/promises'; +import { createExtractorFromData } from 'node-unrar-js'; +import { createInflateRaw, deflateRawSync } from 'zlib'; + +import { getSevenZip } from '../../../common/sevenzip'; +import { CbzService } from './cbz.service'; + +const mockReadFile = readFile as MockedFunction; +const mockCreateReadStream = createReadStream as unknown as MockedFunction; +const mockCreateInflateRaw = createInflateRaw as unknown as MockedFunction; +const mockCreateExtractorFromData = createExtractorFromData as MockedFunction; +const mockGetSevenZip = getSevenZip as MockedFunction; + +interface ZipEntrySpec { + name: string; + data: Buffer; + compression?: 0 | 8; + dataDescriptor?: boolean; + omitDescriptor?: boolean; +} + +function buildCbzBuffer(entries: ZipEntrySpec[]): Buffer { + const chunks: Buffer[] = []; + + for (const entry of entries) { + const fileName = Buffer.from(entry.name, 'utf-8'); + const payload = entry.compression === 8 ? deflateRawSync(entry.data) : entry.data; + + const header = Buffer.alloc(30 + fileName.length); + header.writeUInt32LE(0x04034b50, 0); + header.writeUInt16LE(20, 4); + header.writeUInt16LE(entry.dataDescriptor ? 0x0008 : 0, 6); + header.writeUInt16LE(entry.compression ?? 0, 8); + header.writeUInt32LE(entry.dataDescriptor ? 0 : payload.length, 18); + header.writeUInt32LE(entry.data.length, 22); + header.writeUInt16LE(fileName.length, 26); + header.writeUInt16LE(0, 28); + fileName.copy(header, 30); + + chunks.push(header, payload); + + if (entry.dataDescriptor && !entry.omitDescriptor) { + const descriptor = Buffer.alloc(16); + descriptor.writeUInt32LE(0x08074b50, 0); + descriptor.writeUInt32LE(0, 4); + descriptor.writeUInt32LE(payload.length, 8); + descriptor.writeUInt32LE(entry.data.length, 12); + chunks.push(descriptor); + } + } + + return Buffer.concat(chunks); +} + +async function readStream(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +describe('CbzService', () => { + const user = { id: 9, isSuperuser: false, permissions: [] } as any; + const bookService = { verifyFileAccess: vi.fn() }; + + let service: CbzService; + + beforeEach(() => { + vi.resetAllMocks(); + service = new CbzService(bookService as any); + }); + + it('counts visible CBZ images and caches the parsed index', async () => { + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/one.cbz' }); + mockReadFile.mockResolvedValue( + buildCbzBuffer([ + { name: '.hidden.jpg', data: Buffer.from([1]) }, + { name: 'notes.txt', data: Buffer.from('hi') }, + { name: '10.jpg', data: Buffer.from([10, 10]) }, + { name: '2.png', data: Buffer.from([2, 2]) }, + ]) as unknown as Awaited>, + ); + + await expect(service.getPageCount(1, user)).resolves.toBe(2); + await expect(service.getPageCount(1, user)).resolves.toBe(2); + expect(mockReadFile).toHaveBeenCalledTimes(1); + }); + + it('streams DEFLATE CBZ entries in natural order and pipes through inflate', async () => { + const inflatedStream = { kind: 'inflated' }; + const rawStream = { pipe: vi.fn().mockReturnValue(inflatedStream) }; + const inflateStream = { kind: 'inflate-transform' }; + + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/two.cbz' }); + mockReadFile.mockResolvedValue( + buildCbzBuffer([ + { name: '10.jpg', data: Buffer.from([10]), compression: 8 }, + { name: '2.jpg', data: Buffer.from([2]), compression: 8 }, + ]) as unknown as Awaited>, + ); + mockCreateReadStream.mockReturnValue(rawStream as unknown as ReturnType); + mockCreateInflateRaw.mockReturnValue(inflateStream as ReturnType); + + const result = await service.streamPage(2, 0, user); + + expect(mockCreateReadStream).toHaveBeenCalledWith( + '/books/two.cbz', + expect.objectContaining({ start: expect.any(Number), end: expect.any(Number) }), + ); + expect(rawStream.pipe).toHaveBeenCalledWith(inflateStream); + expect(result.stream).toBe(inflatedStream); + expect(result.mimeType).toBe('image/jpeg'); + }); + + it('streams STORED CBZ entries without inflate', async () => { + const rawStream = { kind: 'raw-stream' }; + + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/three.cbz' }); + mockReadFile.mockResolvedValue( + buildCbzBuffer([{ name: 'cover.png', data: Buffer.from([3, 3]), compression: 0 }]) as unknown as Awaited>, + ); + mockCreateReadStream.mockReturnValue(rawStream as unknown as ReturnType); + + const result = await service.streamPage(3, 0, user); + + expect(mockCreateInflateRaw).not.toHaveBeenCalled(); + expect(result.stream).toBe(rawStream); + expect(result.mimeType).toBe('image/png'); + }); + + it('resolves data-descriptor CBZ entries and ignores unresolved ones', async () => { + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/four.cbz' }); + mockReadFile.mockResolvedValue( + buildCbzBuffer([ + { name: 'broken.jpg', data: Buffer.from([1, 2]), compression: 8, dataDescriptor: true, omitDescriptor: true }, + { name: 'ok.jpg', data: Buffer.from([4, 5]), compression: 8, dataDescriptor: true }, + ]) as unknown as Awaited>, + ); + + await expect(service.getPageCount(4, user)).resolves.toBe(1); + }); + + it('throws for CBZ page indexes outside bounds', async () => { + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbz', absolutePath: '/books/five.cbz' }); + mockReadFile.mockResolvedValue(buildCbzBuffer([{ name: 'only.jpg', data: Buffer.from([1]) }]) as unknown as Awaited>); + + await expect(service.streamPage(5, -1, user)).rejects.toThrow(NotFoundException); + await expect(service.streamPage(5, 1, user)).rejects.toThrow(NotFoundException); + }); + + it('counts CBR pages and streams extracted data', async () => { + const rarBytes = Buffer.from('rar-bytes'); + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbr', absolutePath: '/books/six.cbr' }); + mockReadFile.mockResolvedValue(rarBytes as unknown as Awaited>); + mockCreateExtractorFromData + .mockResolvedValueOnce({ + getFileList: () => ({ + fileHeaders: [ + { name: '10.jpg', flags: { directory: false } }, + { name: '2.jpg', flags: { directory: false } }, + { name: 'notes.txt', flags: { directory: false } }, + { name: '.hidden.jpg', flags: { directory: false } }, + ], + }), + } as any) + .mockResolvedValueOnce({ + extract: () => ({ + files: [{ fileHeader: { flags: { directory: false } }, extraction: Uint8Array.from([7, 8, 9]) }], + }), + } as any); + + await expect(service.getPageCount(6, user)).resolves.toBe(2); + const streamed = await service.streamPage(6, 0, user); + await expect(readStream(streamed.stream)).resolves.toEqual(Buffer.from([7, 8, 9])); + expect(streamed.mimeType).toBe('image/jpeg'); + await expect(service.getPageCount(6, user)).resolves.toBe(2); + expect(mockReadFile).toHaveBeenCalledTimes(1); + }); + + it('throws when CBR extraction does not produce the requested file', async () => { + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbr', absolutePath: '/books/seven.cbr' }); + mockReadFile.mockResolvedValue(Buffer.from('rar') as unknown as Awaited>); + mockCreateExtractorFromData + .mockResolvedValueOnce({ + getFileList: () => ({ fileHeaders: [{ name: '1.jpg', flags: { directory: false } }] }), + } as any) + .mockResolvedValueOnce({ + extract: () => ({ files: [{ fileHeader: { flags: { directory: true } }, extraction: Uint8Array.from([1]) }] }), + } as any); + + await expect(service.streamPage(7, 0, user)).rejects.toThrow(NotFoundException); + }); + + it('throws for CBR page indexes outside bounds', async () => { + bookService.verifyFileAccess.mockResolvedValue({ format: 'cbr', absolutePath: '/books/seven.cbr' }); + mockReadFile.mockResolvedValue(Buffer.from('rar') as unknown as Awaited>); + mockCreateExtractorFromData.mockResolvedValueOnce({ + getFileList: () => ({ fileHeaders: [{ name: '1.jpg', flags: { directory: false } }] }), + } as any); + + await expect(service.streamPage(7, 1, user)).rejects.toThrow(NotFoundException); + }); + + it('extracts CB7 pages once and reuses cached page list', async () => { + const fsApi = { + open: vi.fn().mockReturnValue(3), + write: vi.fn(), + close: vi.fn(), + mkdir: vi.fn(), + readdir: vi.fn().mockReturnValue(['.', '..', '10.jpg', '2.png', '.hidden.jpg', 'notes.txt']), + readFile: vi.fn().mockReturnValue(Uint8Array.from([1, 2, 3])), + }; + const sevenZip = { FS: fsApi, callMain: vi.fn() }; + + bookService.verifyFileAccess.mockResolvedValue({ format: 'cb7', absolutePath: '/books/eight.cb7' }); + mockReadFile.mockResolvedValue(Buffer.from('7z') as unknown as Awaited>); + mockGetSevenZip.mockResolvedValue(sevenZip as any); + + await expect(service.getPageCount(8, user)).resolves.toBe(2); + const result = await service.streamPage(8, 0, user); + await expect(readStream(result.stream)).resolves.toEqual(Buffer.from([1, 2, 3])); + expect(result.mimeType).toBe('image/png'); + expect(fsApi.readFile).toHaveBeenCalledWith('/p8/2.png'); + expect(sevenZip.callMain).toHaveBeenCalledTimes(1); + }); + + it('throws for unsupported formats and CB7 index bounds', async () => { + bookService.verifyFileAccess + .mockResolvedValueOnce({ format: 'pdf', absolutePath: '/books/nine.pdf' }) + .mockResolvedValueOnce({ format: 'pdf', absolutePath: '/books/nine.pdf' }) + .mockResolvedValueOnce({ format: 'cb7', absolutePath: '/books/nine.cb7' }); + + const fsApi = { + open: vi.fn().mockReturnValue(4), + write: vi.fn(), + close: vi.fn(), + mkdir: vi.fn(), + readdir: vi.fn().mockReturnValue(['.', '..', '1.jpg']), + readFile: vi.fn(), + }; + mockGetSevenZip.mockResolvedValue({ FS: fsApi, callMain: vi.fn() } as any); + mockReadFile.mockResolvedValue(Buffer.from('7z') as unknown as Awaited>); + + await expect(service.getPageCount(9, user)).rejects.toThrow(NotFoundException); + await expect(service.streamPage(9, 0, user)).rejects.toThrow(NotFoundException); + await expect(service.streamPage(9, 1, user)).rejects.toThrow(NotFoundException); + }); +}); diff --git a/server/src/modules/reader/epub/epub.controller.test.ts b/server/src/modules/reader/epub/epub.controller.test.ts new file mode 100644 index 000000000..4c83a5c99 --- /dev/null +++ b/server/src/modules/reader/epub/epub.controller.test.ts @@ -0,0 +1,90 @@ +import { BadRequestException } from '@nestjs/common'; +import { PassThrough } from 'stream'; + +import { EpubController } from './epub.controller'; + +describe('EpubController', () => { + const epubService = { + getBookInfo: vi.fn(), + streamFile: vi.fn(), + }; + + const controller = new EpubController(epubService as any); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('passes undefined fileId when query is absent', async () => { + const user = { id: 5, isSuperuser: false, permissions: [] } as any; + epubService.getBookInfo.mockResolvedValue({ title: 'ok' }); + + await controller.getBookInfo(11, undefined, user); + + expect(epubService.getBookInfo).toHaveBeenCalledWith(11, undefined, user); + }); + + it('parses numeric fileId query values', async () => { + const user = { id: 5, isSuperuser: false, permissions: [] } as any; + epubService.getBookInfo.mockResolvedValue({ title: 'ok' }); + + await controller.getBookInfo(11, ' 12 ', user); + + expect(epubService.getBookInfo).toHaveBeenCalledWith(11, 12, user); + }); + + it.each(['abc', '12abc', '-1', '0'])('rejects invalid fileId query: %s', (fileId) => { + expect(() => controller.getBookInfo(11, fileId, { id: 5, isSuperuser: false, permissions: [] } as any)).toThrow( + new BadRequestException('Invalid fileId'), + ); + }); + + it('decodes wildcard file path, sets headers, and streams payload', async () => { + const user = { id: 1, isSuperuser: false, permissions: [] } as any; + const stream = new PassThrough(); + const reply = { + header: vi.fn(), + send: vi.fn(), + }; + + epubService.streamFile.mockResolvedValue({ + stream, + contentType: 'application/xhtml+xml', + size: 321, + }); + + await controller.getFile(9, 'OPS/text/Chapter%201.xhtml', '13', user, reply as any); + + expect(epubService.streamFile).toHaveBeenCalledWith(9, 'OPS/text/Chapter 1.xhtml', 13, user); + expect(reply.header).toHaveBeenNthCalledWith(1, 'Content-Type', 'application/xhtml+xml'); + expect(reply.header).toHaveBeenNthCalledWith(2, 'Content-Length', 321); + expect(reply.header).toHaveBeenNthCalledWith(3, 'Cache-Control', 'public, max-age=3600'); + expect(reply.send).toHaveBeenCalledWith(stream); + }); + + it('does not set content-length when size is zero', async () => { + const user = { id: 1, isSuperuser: false, permissions: [] } as any; + const stream = new PassThrough(); + const reply = { + header: vi.fn(), + send: vi.fn(), + }; + epubService.streamFile.mockResolvedValue({ + stream, + contentType: 'application/xml', + size: 0, + }); + + await controller.getFile(9, 'META-INF/container.xml', undefined, user, reply as any); + + expect(reply.header).toHaveBeenCalledWith('Content-Type', 'application/xml'); + expect(reply.header).not.toHaveBeenCalledWith('Content-Length', expect.anything()); + expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'public, max-age=3600'); + }); + + it('rejects malformed encoded file paths', async () => { + await expect( + controller.getFile(9, 'OPS/text/%E0%A4%A', undefined, { id: 1, isSuperuser: false, permissions: [] } as any, {} as any), + ).rejects.toThrow(new BadRequestException('Invalid file path')); + }); +}); diff --git a/server/src/modules/reader/epub/epub.controller.ts b/server/src/modules/reader/epub/epub.controller.ts index 79036c451..4347d9e6a 100644 --- a/server/src/modules/reader/epub/epub.controller.ts +++ b/server/src/modules/reader/epub/epub.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, ParseIntPipe, Query, Res } from '@nestjs/common'; +import { BadRequestException, Controller, Get, Param, ParseIntPipe, Query, Res } from '@nestjs/common'; import type { FastifyReply } from 'fastify'; import { CurrentUser } from '../../../common/decorators/current-user.decorator'; @@ -11,7 +11,7 @@ export class EpubController { @Get(':bookId/info') getBookInfo(@Param('bookId', ParseIntPipe) bookId: number, @Query('fileId') fileId: string | undefined, @CurrentUser() user: RequestUser) { - return this.epubService.getBookInfo(bookId, fileId != null ? Number(fileId) : undefined, user); + return this.epubService.getBookInfo(bookId, this.parseFileId(fileId), user); } @Get(':bookId/file/*') @@ -22,16 +22,38 @@ export class EpubController { @CurrentUser() user: RequestUser, @Res() reply: FastifyReply, ) { - const filePath = encodedPath - .split('/') - .map((s) => decodeURIComponent(s)) - .join('/'); + const filePath = this.decodePathParam(encodedPath); - const { stream, contentType, size } = await this.epubService.streamFile(bookId, filePath, fileId != null ? Number(fileId) : undefined, user); + const { stream, contentType, size } = await this.epubService.streamFile(bookId, filePath, this.parseFileId(fileId), user); reply.header('Content-Type', contentType); if (size > 0) reply.header('Content-Length', size); reply.header('Cache-Control', 'public, max-age=3600'); reply.send(stream); } + + private parseFileId(fileId: string | undefined): number | undefined { + if (fileId === undefined) return undefined; + const value = fileId.trim(); + if (!/^\d+$/.test(value)) { + throw new BadRequestException('Invalid fileId'); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new BadRequestException('Invalid fileId'); + } + return parsed; + } + + private decodePathParam(encodedPath: string): string { + try { + return encodedPath + .split('/') + .map((segment) => decodeURIComponent(segment)) + .join('/'); + } catch { + throw new BadRequestException('Invalid file path'); + } + } } diff --git a/server/src/modules/reader/epub/epub.module.test.ts b/server/src/modules/reader/epub/epub.module.test.ts new file mode 100644 index 000000000..d2a4e760e --- /dev/null +++ b/server/src/modules/reader/epub/epub.module.test.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; + +vi.mock('../../book/book.module', () => ({ BookModule: class BookModule {} })); +vi.mock('../../library/library.module', () => ({ LibraryModule: class LibraryModule {} })); + +import { BookModule } from '../../book/book.module'; +import { LibraryModule } from '../../library/library.module'; +import { EpubController } from './epub.controller'; +import { EpubModule } from './epub.module'; +import { EpubService } from './epub.service'; + +describe('EpubModule', () => { + it('registers expected imports/controllers/providers', () => { + expect(Reflect.getMetadata('imports', EpubModule)).toEqual([BookModule, LibraryModule]); + expect(Reflect.getMetadata('controllers', EpubModule)).toEqual([EpubController]); + expect(Reflect.getMetadata('providers', EpubModule)).toEqual([EpubService]); + }); +}); diff --git a/server/src/modules/reader/epub/epub.service.test.ts b/server/src/modules/reader/epub/epub.service.test.ts new file mode 100644 index 000000000..55caa5915 --- /dev/null +++ b/server/src/modules/reader/epub/epub.service.test.ts @@ -0,0 +1,365 @@ +vi.mock('fs/promises', () => ({ stat: vi.fn() })); +vi.mock('unzipper', () => ({ Open: { file: vi.fn() } })); + +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { stat } from 'fs/promises'; +import { Readable } from 'stream'; +import * as unzipper from 'unzipper'; + +import { EpubService } from './epub.service'; + +const mockStat = stat as MockedFunction; +const mockOpenFile = (unzipper as any).Open.file as vi.Mock; + +interface ZipEntrySpec { + path: string; + content: string | Buffer; + uncompressedSize?: number; + streamContent?: string | Buffer; + bufferError?: Error; +} + +function zipEntry(spec: ZipEntrySpec) { + const content = typeof spec.content === 'string' ? Buffer.from(spec.content) : spec.content; + const streamContent = typeof spec.streamContent === 'string' ? Buffer.from(spec.streamContent) : (spec.streamContent ?? content); + return { + path: spec.path, + uncompressedSize: spec.uncompressedSize ?? content.length, + buffer: spec.bufferError ? vi.fn().mockRejectedValue(spec.bufferError) : vi.fn().mockResolvedValue(content), + stream: vi.fn(() => Readable.from(streamContent)), + }; +} + +function makeArchive(entries: ZipEntrySpec[]) { + return { files: entries.map(zipEntry) }; +} + +async function readStream(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +const CONTAINER_XML = ` + + + + + +`; + +const OPF_XML = ` + + + Reader Test + Jane Doe + en + Acme + A sample EPUB + book-1 + + + + + + + + + + + + + + + +`; + +const NAV_XHTML = ` + + + + + +`; + +const NCX_XML = ` + + + + NCX Chapter + + + NCX Child + + + + + +`; + +const NAV_EDGE_XHTML = ` + + + + + +`; + +function makeEpubArchive(options?: { navBufferError?: boolean; omitChapterFile?: boolean; lowerCasePaths?: boolean }) { + const lower = options?.lowerCasePaths === true; + const ops = lower ? 'ops' : 'OPS'; + const navSpec: ZipEntrySpec = options?.navBufferError + ? { path: `${ops}/nav.xhtml`, content: NAV_XHTML, bufferError: new Error('bad nav') } + : { path: `${ops}/nav.xhtml`, content: NAV_XHTML }; + + return makeArchive([ + { path: 'META-INF/container.xml', content: CONTAINER_XML }, + { path: `${ops}/content.opf`, content: OPF_XML }, + navSpec, + { path: `${ops}/toc.ncx`, content: NCX_XML }, + ...(options?.omitChapterFile + ? [] + : [{ path: `${ops}/text/ch1.xhtml`, content: '

ch1

', streamContent: 'chapter-one', uncompressedSize: 11 }]), + { path: `${ops}/text/ch2.xhtml`, content: '

ch2

' }, + { path: `${ops}/styles/main.css`, content: 'body {}' }, + { path: `${ops}/images/cover.jpg`, content: Buffer.from([0xff, 0xd8, 0xff]) }, + { path: 'META-INF/encryption.xml', content: '', streamContent: '' }, + ]); +} + +describe('EpubService', () => { + const user = { id: 10, isSuperuser: false, permissions: [] } as any; + const bookReadService = { + findLibraryIdByBookId: vi.fn(), + findFileById: vi.fn(), + findPrimaryFilesByBookIds: vi.fn(), + }; + const libraryService = { + verifyUserAccess: vi.fn(), + }; + + let service: EpubService; + + beforeEach(() => { + vi.resetAllMocks(); + service = new EpubService(bookReadService as any, libraryService as any); + bookReadService.findLibraryIdByBookId.mockResolvedValue(3); + bookReadService.findPrimaryFilesByBookIds.mockResolvedValue([{ format: 'epub', absolutePath: '/books/book.epub' }]); + libraryService.verifyUserAccess.mockResolvedValue(undefined); + mockStat.mockResolvedValue({ mtimeMs: 100 } as Awaited>); + }); + + it('parses EPUB metadata, spine, TOC, and optional META-INF files', async () => { + mockOpenFile.mockResolvedValueOnce(makeEpubArchive({ lowerCasePaths: true }) as any); + + const info = await service.getBookInfo(99, undefined, user); + + expect(info.containerPath).toBe('OPS/content.opf'); + expect(info.rootPath).toBe('OPS/'); + expect(info.coverPath).toBe('OPS/images/cover.jpg'); + expect(info.optionalFiles).toContain('META-INF/encryption.xml'); + expect(info.metadata).toEqual( + expect.objectContaining({ + title: 'Reader Test', + creator: 'Jane Doe', + language: 'en', + publisher: 'Acme', + description: 'A sample EPUB', + identifier: 'book-1', + }), + ); + expect(info.spine).toEqual([ + { idref: 'chap1', href: 'OPS/text/ch1.xhtml', mediaType: 'application/xhtml+xml', linear: true }, + { idref: 'chap2', href: 'OPS/text/ch2.xhtml', mediaType: 'application/xhtml+xml', linear: false }, + ]); + expect(info.toc).toEqual( + expect.objectContaining({ + label: 'Table of Contents', + children: [ + { label: 'Chapter 1', href: 'OPS/text/ch1.xhtml', children: undefined }, + { + label: 'Chapter 2', + href: 'OPS/text/ch2.xhtml#part', + children: [{ label: 'Chapter 3', href: 'text/ch3.xhtml', children: undefined }], + }, + ], + }), + ); + expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(10, 3, false); + }); + + it('falls back to NCX TOC when nav parsing fails', async () => { + mockOpenFile.mockResolvedValueOnce(makeEpubArchive({ navBufferError: true }) as any); + + const info = await service.getBookInfo(99, undefined, user); + + expect(info.toc).toEqual( + expect.objectContaining({ + label: 'Table of Contents', + children: [ + { + label: 'NCX Chapter', + href: 'OPS/text/ch1.xhtml', + children: [{ label: 'NCX Child', href: 'OPS/text/ch2.xhtml#frag', children: undefined }], + }, + ], + }), + ); + }); + + it('handles nav/text edge cases and malformed percent-encoded href values', async () => { + const opfWithMalformedHref = OPF_XML.replace('href="styles/main.css"', 'href="styles/%E0%A4%A.css"'); + mockOpenFile.mockResolvedValueOnce( + makeArchive([ + { path: 'META-INF/container.xml', content: CONTAINER_XML }, + { path: 'OPS/content.opf', content: opfWithMalformedHref }, + { path: 'OPS/nav.xhtml', content: NAV_EDGE_XHTML }, + { path: 'OPS/toc.ncx', content: NCX_XML }, + { path: 'OPS/text/ch1.xhtml', content: '

ch1

' }, + ]) as any, + ); + + const info = await service.getBookInfo(99, undefined, user); + + expect(info.toc?.children).toEqual([ + { label: 'Plain Label', href: undefined, children: undefined }, + { label: 'External Link', href: 'http://example.com/book', children: undefined }, + { label: '7', href: 'OPS/text/ch1.xhtml', children: undefined }, + ]); + expect(info.manifest.some((item) => item.href === 'OPS/styles/%E0%A4%A.css')).toBe(true); + }); + + it('rejects stream requests with invalid paths', async () => { + await expect(service.streamFile(1, '../OPS/text/ch1.xhtml', undefined, user)).rejects.toThrow(ForbiddenException); + await expect(service.streamFile(1, '/', undefined, user)).rejects.toThrow(ForbiddenException); + }); + + it('streams manifest entries with manifest media type and size', async () => { + mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any).mockResolvedValueOnce(makeEpubArchive() as any); + + const result = await service.streamFile(1, 'OPS/text/ch1.xhtml', undefined, user); + + expect(result.contentType).toBe('application/xhtml+xml'); + expect(result.size).toBe(11); + await expect(readStream(result.stream)).resolves.toEqual(Buffer.from('chapter-one')); + }); + + it('streams optional META-INF files using guessed content type', async () => { + mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any).mockResolvedValueOnce(makeEpubArchive() as any); + + const result = await service.streamFile(1, 'META-INF/encryption.xml', undefined, user); + + expect(result.contentType).toBe('application/xml'); + await expect(readStream(result.stream)).resolves.toEqual(Buffer.from('')); + }); + + it('throws when requested path is outside parsed manifest/optional files', async () => { + mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any); + + await expect(service.streamFile(1, 'OPS/text/missing.xhtml', undefined, user)).rejects.toThrow(NotFoundException); + expect(mockOpenFile).toHaveBeenCalledTimes(1); + }); + + it('throws when parsed path exists but archive entry cannot be found at stream time', async () => { + mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any).mockResolvedValueOnce(makeEpubArchive({ omitChapterFile: true }) as any); + + await expect(service.streamFile(1, 'OPS/text/ch1.xhtml', undefined, user)).rejects.toThrow(NotFoundException); + }); + + it('uses cache when mtime is unchanged and reparses after mtime update', async () => { + const titleOne = OPF_XML.replace('Reader Test', 'Title One'); + const titleTwo = OPF_XML.replace('Reader Test', 'Title Two'); + mockStat + .mockResolvedValueOnce({ mtimeMs: 200 } as Awaited>) + .mockResolvedValueOnce({ mtimeMs: 200 } as Awaited>); + mockOpenFile.mockResolvedValueOnce( + makeArchive([ + { path: 'META-INF/container.xml', content: CONTAINER_XML }, + { path: 'OPS/content.opf', content: titleOne }, + { path: 'OPS/nav.xhtml', content: NAV_XHTML }, + { path: 'OPS/toc.ncx', content: NCX_XML }, + ]) as any, + ); + + const first = await service.getBookInfo(1, undefined, user); + const second = await service.getBookInfo(1, undefined, user); + expect(first.metadata['title']).toBe('Title One'); + expect(second.metadata['title']).toBe('Title One'); + expect(mockOpenFile).toHaveBeenCalledTimes(1); + + mockStat.mockResolvedValueOnce({ mtimeMs: 300 } as Awaited>); + mockOpenFile.mockResolvedValueOnce( + makeArchive([ + { path: 'META-INF/container.xml', content: CONTAINER_XML }, + { path: 'OPS/content.opf', content: titleTwo }, + { path: 'OPS/nav.xhtml', content: NAV_XHTML }, + { path: 'OPS/toc.ncx', content: NCX_XML }, + ]) as any, + ); + + const third = await service.getBookInfo(1, undefined, user); + expect(third.metadata['title']).toBe('Title Two'); + expect(mockOpenFile).toHaveBeenCalledTimes(2); + }); + + it('validates EPUB path resolution for book and file constraints', async () => { + bookReadService.findLibraryIdByBookId.mockResolvedValueOnce(null); + await expect(service.getBookInfo(5, undefined, user)).rejects.toThrow(new NotFoundException('Book 5 not found')); + + bookReadService.findLibraryIdByBookId.mockResolvedValue(3); + bookReadService.findFileById.mockResolvedValueOnce({ bookId: 6, format: 'epub', absolutePath: '/books/other.epub' }); + await expect(service.getBookInfo(5, 77, user)).rejects.toThrow(new NotFoundException('File 77 not found for book 5')); + + bookReadService.findFileById.mockResolvedValueOnce({ bookId: 5, format: 'pdf', absolutePath: '/books/file.pdf' }); + await expect(service.getBookInfo(5, 77, user)).rejects.toThrow(new NotFoundException('File 77 is not an EPUB file')); + + bookReadService.findPrimaryFilesByBookIds.mockResolvedValueOnce([{ format: 'pdf', absolutePath: '/books/file.pdf' }]); + await expect(service.getBookInfo(5, undefined, user)).rejects.toThrow(new NotFoundException('No primary EPUB file for book 5')); + }); + + it('uses explicit fileId path when valid and skips primary-file lookup', async () => { + bookReadService.findFileById.mockResolvedValue({ bookId: 5, format: 'epub', absolutePath: '/books/alt.epub' }); + mockOpenFile.mockResolvedValueOnce(makeEpubArchive() as any); + + await service.getBookInfo(5, 88, user); + + expect(bookReadService.findFileById).toHaveBeenCalledWith(88); + expect(bookReadService.findPrimaryFilesByBookIds).not.toHaveBeenCalled(); + }); + + it('evicts oldest cache entry when capacity is reached', () => { + const cache = (service as any).cache as Map; + for (let i = 0; i < 50; i += 1) { + cache.set(`book-${i}`, { + info: {} as any, + mtime: i, + validPaths: new Set(), + lastAccessed: i, + }); + } + + (service as any).evict(); + + expect(cache.size).toBe(49); + expect(cache.has('book-0')).toBe(false); + }); +}); diff --git a/server/src/modules/reading-session/dto/save-reading-session.dto.test.ts b/server/src/modules/reading-session/dto/save-reading-session.dto.test.ts new file mode 100644 index 000000000..079992b5e --- /dev/null +++ b/server/src/modules/reading-session/dto/save-reading-session.dto.test.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { SaveReadingSessionDto } from './save-reading-session.dto'; + +describe('SaveReadingSessionDto', () => { + it('accepts valid payloads including nullable progress fields', async () => { + const dto = plainToInstance(SaveReadingSessionDto, { + sessionId: 'session-1', + startedAt: '2026-04-15T10:00:00.000Z', + endedAt: '2026-04-15T10:02:00.000Z', + durationSeconds: 120, + progressDelta: null, + endProgress: null, + }); + + expect(await validate(dto)).toEqual([]); + }); + + it('rejects invalid session id and invalid progress bounds', async () => { + const invalid = plainToInstance(SaveReadingSessionDto, { + sessionId: '', + startedAt: '2026-04-15T10:00:00.000Z', + endedAt: '2026-04-15T10:02:00.000Z', + durationSeconds: -1, + progressDelta: 'bad', + endProgress: 101, + }); + + const errors = await validate(invalid); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/reading-session/reading-session.controller.test.ts b/server/src/modules/reading-session/reading-session.controller.test.ts new file mode 100644 index 000000000..3473f380c --- /dev/null +++ b/server/src/modules/reading-session/reading-session.controller.test.ts @@ -0,0 +1,23 @@ +import { ReadingSessionController } from './reading-session.controller'; + +describe('ReadingSessionController', () => { + it('delegates saveSession to ReadingSessionService', async () => { + const service = { + save: vi.fn().mockResolvedValue(undefined), + }; + const controller = new ReadingSessionController(service as never); + const user = { id: 17 } as never; + const dto = { + sessionId: 'session-1', + startedAt: '2026-04-15T10:00:00.000Z', + endedAt: '2026-04-15T10:05:00.000Z', + durationSeconds: 300, + progressDelta: 1.2, + endProgress: 44, + }; + + await controller.saveSession(42, dto, user); + + expect(service.save).toHaveBeenCalledWith(42, dto, user); + }); +}); diff --git a/server/src/modules/reading-session/reading-session.module.test.ts b/server/src/modules/reading-session/reading-session.module.test.ts new file mode 100644 index 000000000..08baa793b --- /dev/null +++ b/server/src/modules/reading-session/reading-session.module.test.ts @@ -0,0 +1,17 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { BookModule } from '../book/book.module'; +import { ReadingSessionController } from './reading-session.controller'; +import { ReadingSessionModule } from './reading-session.module'; +import { ReadingSessionRepository } from './reading-session.repository'; +import { ReadingSessionService } from './reading-session.service'; + +describe('ReadingSessionModule', () => { + it('registers expected imports, controller, and providers', () => { + expect(Reflect.getMetadata(MODULE_METADATA.IMPORTS, ReadingSessionModule)).toEqual([BookModule]); + expect(Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, ReadingSessionModule)).toEqual([ReadingSessionController]); + expect(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, ReadingSessionModule)).toEqual([ReadingSessionService, ReadingSessionRepository]); + }); +}); diff --git a/server/src/modules/scanner/file-watcher.lifecycle.test.ts b/server/src/modules/scanner/file-watcher.lifecycle.test.ts new file mode 100644 index 000000000..1b851479c --- /dev/null +++ b/server/src/modules/scanner/file-watcher.lifecycle.test.ts @@ -0,0 +1,251 @@ +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn(), +})); + +import { Logger } from '@nestjs/common'; +import type { MockedFunction } from 'vitest'; +import { subscribe } from '@parcel/watcher'; + +import { FileEventProcessorService } from './file-event-processor.service'; +import { FileWatcherService } from './file-watcher.service'; +import { ScanGateway } from './scan.gateway'; +import { ScannerService } from './scanner.service'; + +const mockSubscribe = subscribe as MockedFunction; + +function makeService(db: any = {}) { + const processor = { + handleUnlink: vi.fn().mockResolvedValue({ type: 'noop' }), + handleUnlinkDir: vi.fn().mockResolvedValue({ type: 'noop' }), + handleCreate: vi.fn().mockResolvedValue({ type: 'noop' }), + reconcileMissingBooks: vi.fn().mockResolvedValue([]), + } as unknown as FileEventProcessorService; + + const gateway = { + emitBookMissing: vi.fn(), + emitBookRestored: vi.fn(), + emitBookMoved: vi.fn(), + } as unknown as ScanGateway; + + const scannerService = { + startScanAsync: vi.fn(), + scanBookFolderAsync: vi.fn(), + isScanRunning: vi.fn().mockReturnValue(false), + } as unknown as ScannerService; + + const service = new FileWatcherService(db, processor, gateway, scannerService); + return { service, processor, gateway, scannerService }; +} + +function makeBootstrapDb(watchedLibraries: Array<{ id: number }> = []): any { + const select = vi.fn(); + select.mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(watchedLibraries), + }), + }); + + for (let i = 0; i < watchedLibraries.length; i++) { + select.mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ path: '/books' }]), + }), + }); + } + + return { select }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + mockSubscribe.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('FileWatcherService lifecycle and watcher controls', () => { + it('fails bootstrap when watched-library query throws', async () => { + const { service } = makeService({ + select: vi.fn(() => { + throw new Error('db unavailable'); + }), + }); + + await expect(service.onApplicationBootstrap()).rejects.toThrow('db unavailable'); + }); + + it('logs interval reconcile failures from the background timer', async () => { + const { service, processor } = makeService(makeBootstrapDb()); + (service as any).subscriptions.set(1, []); + (processor.reconcileMissingBooks as vi.Mock).mockRejectedValue(new Error('reconcile exploded')); + + await service.onApplicationBootstrap(); + await vi.advanceTimersByTimeAsync(30 * 60 * 1_000); + + expect(Logger.prototype.error).toHaveBeenCalledWith(expect.stringContaining('[scanner.watcher.reconcile] [fail]')); + await service.onModuleDestroy(); + }); + + it('starts no watcher subscriptions when no library paths are configured', async () => { + const { service } = makeService(); + + await expect(service.startWatcher(7, [])).resolves.toBeUndefined(); + expect(mockSubscribe).not.toHaveBeenCalled(); + }); + + it('subscribes to paths and normalizes create/update/delete events', async () => { + const unsubscribe = vi.fn().mockResolvedValue(undefined); + const callbacks: Array<(error: Error | null, events: Array<{ type: string; path: string }>) => void> = []; + mockSubscribe.mockImplementation((_path, cb) => { + callbacks.push(cb as any); + return Promise.resolve({ unsubscribe } as any); + }); + + const { service } = makeService(); + const scheduleSpy = vi.spyOn(service as any, 'schedule').mockImplementation(() => undefined); + + await service.startWatcher(2, ['/books']); + callbacks[0](new Error('watch callback failure'), []); + callbacks[0](null, [ + { type: 'create', path: '/books/new.epub' }, + { type: 'update', path: '/books/edit.epub' }, + { type: 'delete', path: '/books/gone.epub' }, + { type: 'bogus', path: '/books/ignored.epub' }, + ]); + + expect(scheduleSpy).toHaveBeenNthCalledWith(1, 'create', '/books/new.epub', 2); + expect(scheduleSpy).toHaveBeenNthCalledWith(2, 'create', '/books/edit.epub', 2); + expect(scheduleSpy).toHaveBeenNthCalledWith(3, 'delete', '/books/gone.epub', 2); + expect(scheduleSpy).toHaveBeenCalledTimes(3); + expect(unsubscribe).not.toHaveBeenCalled(); + }); + + it('cleans up partially started watchers when one path subscription fails', async () => { + const firstUnsubscribe = vi.fn().mockResolvedValue(undefined); + mockSubscribe.mockResolvedValueOnce({ unsubscribe: firstUnsubscribe } as any).mockRejectedValueOnce(new Error('permission denied')); + + const { service } = makeService(); + + await expect(service.startWatcher(3, ['/books/ok', '/books/fail'])).rejects.toThrow('permission denied'); + expect(firstUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('returns immediately when stopping a library without an active watcher', async () => { + const { service } = makeService(); + + await expect(service.stopWatcher(999)).resolves.toBeUndefined(); + }); + + it('stops active watchers and clears pending timers scoped to the library', async () => { + const { service } = makeService(); + const unsubscribe = vi.fn().mockResolvedValue(undefined); + (service as any).subscriptions.set(11, [{ unsubscribe }]); + + const timerA = setTimeout(() => undefined, 1000); + const timerB = setTimeout(() => undefined, 1000); + (service as any).pendingTimers.set('/books/a', { timer: timerA, type: 'create', libraryId: 11 }); + (service as any).pendingTimers.set('/books/b', { timer: timerB, type: 'create', libraryId: 99 }); + + const folderTimerA = setTimeout(() => undefined, 1000); + const folderTimerB = setTimeout(() => undefined, 1000); + (service as any).pendingFolderScanTimers.set('/books/a', { timer: folderTimerA, libraryId: 11 }); + (service as any).pendingFolderScanTimers.set('/books/b', { timer: folderTimerB, libraryId: 99 }); + + const rescanTimer = setTimeout(() => undefined, 1000); + (service as any).pendingLibraryRescanTimers.set(11, rescanTimer); + + const suppressionTimerA = setTimeout(() => undefined, 1000); + const suppressionTimerB = setTimeout(() => undefined, 1000); + (service as any).suppressedDirScans.set(11, new Set(['/books/new'])); + (service as any).suppressedDirScanTimers.set('11:/books/new', suppressionTimerA); + (service as any).suppressedDirScanTimers.set('12:/books/other', suppressionTimerB); + + await service.stopWatcher(11); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect((service as any).pendingTimers.has('/books/a')).toBe(false); + expect((service as any).pendingTimers.has('/books/b')).toBe(true); + expect((service as any).pendingFolderScanTimers.has('/books/a')).toBe(false); + expect((service as any).pendingFolderScanTimers.has('/books/b')).toBe(true); + expect((service as any).pendingLibraryRescanTimers.has(11)).toBe(false); + expect((service as any).suppressedDirScans.has(11)).toBe(false); + expect((service as any).suppressedDirScanTimers.has('11:/books/new')).toBe(false); + expect((service as any).suppressedDirScanTimers.has('12:/books/other')).toBe(true); + }); + + it('throws when stopWatcher cannot unsubscribe from an active subscription', async () => { + const { service } = makeService(); + (service as any).subscriptions.set(12, [ + { + unsubscribe: vi.fn().mockRejectedValue(new Error('unsubscribe failed')), + }, + ]); + + await expect(service.stopWatcher(12)).rejects.toThrow('unsubscribe failed'); + }); + + it('clears all subscriptions during module destroy and propagates unsubscribe failures', async () => { + const { service } = makeService(); + const ok = vi.fn().mockResolvedValue(undefined); + const bad = vi.fn().mockRejectedValue(new Error('destroy failed')); + (service as any).subscriptions.set(1, [{ unsubscribe: ok }]); + + await expect(service.onModuleDestroy()).resolves.toBeUndefined(); + expect(ok).toHaveBeenCalledTimes(1); + + (service as any).subscriptions.set(2, [{ unsubscribe: bad }]); + await expect(service.onModuleDestroy()).rejects.toThrow('destroy failed'); + }); + + it('reconcile surfaces processor errors when watched libraries exist', async () => { + const { service, processor } = makeService(); + (service as any).subscriptions.set(5, []); + (processor.reconcileMissingBooks as vi.Mock).mockRejectedValue(new Error('broken reconcile')); + + await expect((service as any).reconcile()).rejects.toThrow('broken reconcile'); + }); + + it('debounces cross-library rescans and skips the source library', () => { + const { service, scannerService } = makeService(); + (service as any).subscriptions.set(1, []); + (service as any).subscriptions.set(2, []); + (service as any).subscriptions.set(3, []); + + (service as any).scheduleCrossLibraryRescan(1); + (service as any).scheduleCrossLibraryRescan(1); + + vi.advanceTimersByTime(1_500); + expect(scannerService.startScanAsync).toHaveBeenCalledWith(2); + expect(scannerService.startScanAsync).toHaveBeenCalledWith(3); + expect(scannerService.startScanAsync).toHaveBeenCalledTimes(2); + }); + + it('suppresses folder scans temporarily for newly created directories', () => { + const { service } = makeService(); + + (service as any).suppressFolderScansForDir('/books/new', 4); + expect((service as any).isFolderScanSuppressed('/books/new/chapter.epub', 4)).toBe(true); + + vi.advanceTimersByTime(6_000); + expect((service as any).isFolderScanSuppressed('/books/new/chapter.epub', 4)).toBe(false); + expect((service as any).isFolderScanSuppressed('/books/other/chapter.epub', 4)).toBe(false); + }); + + it('logs errors when debounced process execution fails', async () => { + const { service } = makeService(); + vi.spyOn(service as any, 'process').mockRejectedValue(new Error('process failed')); + + (service as any).schedule('delete', '/books/fail.epub', 2); + vi.runAllTimers(); + await Promise.resolve(); + + expect(Logger.prototype.error).toHaveBeenCalledWith(expect.stringContaining('[scanner.watcher.process_event] [fail]')); + }); +}); diff --git a/server/src/modules/scanner/scan.gateway.test.ts b/server/src/modules/scanner/scan.gateway.test.ts index 30cece272..93acacd12 100644 --- a/server/src/modules/scanner/scan.gateway.test.ts +++ b/server/src/modules/scanner/scan.gateway.test.ts @@ -1,9 +1,13 @@ -import { ScanGateway } from './scan.gateway'; import type { BookMissingEvent, CoverRefreshedEvent, ScanProgressEvent } from '@projectx/types'; +import { ScanGateway } from './scan.gateway'; + function makeGateway() { - const gateway = new ScanGateway({ verify: vi.fn() } as any, { validateUser: vi.fn() } as any, { get: vi.fn(), isRunning: vi.fn() } as any); - return gateway; + const jwtService = { verify: vi.fn() }; + const authService = { validateUser: vi.fn() }; + const scanJobStore = { get: vi.fn(), isRunning: vi.fn() }; + const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any); + return { gateway, jwtService, authService, scanJobStore }; } function mockServer() { @@ -16,7 +20,7 @@ function mockServer() { describe('emitBookMissing', () => { it('emits book:missing to the correct library room', () => { - const gateway = makeGateway(); + const { gateway } = makeGateway(); const { server, to, emit } = mockServer(); gateway['server'] = server as any; @@ -28,7 +32,7 @@ describe('emitBookMissing', () => { }); it('does not throw when server is undefined (application context)', () => { - const gateway = makeGateway(); + const { gateway } = makeGateway(); gateway['server'] = undefined as any; expect(() => gateway.emitBookMissing({ libraryId: 1, bookIds: [1] })).not.toThrow(); @@ -39,7 +43,7 @@ describe('emitBookMissing', () => { describe('emitProgress', () => { it('emits scan:progress to the correct room', () => { - const gateway = makeGateway(); + const { gateway } = makeGateway(); const { server, to, emit } = mockServer(); gateway['server'] = server as any; @@ -60,7 +64,7 @@ describe('emitProgress', () => { }); it('does not throw when server is undefined', () => { - const gateway = makeGateway(); + const { gateway } = makeGateway(); gateway['server'] = undefined as any; expect(() => @@ -71,7 +75,7 @@ describe('emitProgress', () => { describe('emitCoverRefreshed', () => { it('emits cover:refreshed to the correct room', () => { - const gateway = makeGateway(); + const { gateway } = makeGateway(); const { server, to, emit } = mockServer(); gateway['server'] = server as any; @@ -82,3 +86,116 @@ describe('emitCoverRefreshed', () => { expect(emit).toHaveBeenCalledWith('cover:refreshed', event); }); }); + +describe('handleConnection', () => { + it('stores validated user on the socket when token is valid', async () => { + const { gateway, jwtService, authService } = makeGateway(); + jwtService.verify.mockReturnValue({ sub: 42, ver: 3 }); + authService.validateUser.mockResolvedValue({ id: 42, username: 'reader' }); + const disconnect = vi.fn(); + const client = { + id: 'sock-1', + handshake: { auth: { token: 'valid' } }, + data: {}, + disconnect, + } as any; + + await gateway.handleConnection(client); + + expect(jwtService.verify).toHaveBeenCalledWith('valid'); + expect(authService.validateUser).toHaveBeenCalledWith(42, 3); + expect(client.data.user).toEqual({ id: 42, username: 'reader' }); + expect(disconnect).not.toHaveBeenCalled(); + }); + + it('disconnects the socket when token is missing or invalid', async () => { + const { gateway, jwtService } = makeGateway(); + jwtService.verify.mockImplementation(() => { + throw new Error('bad token'); + }); + const disconnect = vi.fn(); + const client = { + id: 'sock-2', + handshake: { auth: {} }, + data: {}, + disconnect, + } as any; + + await gateway.handleConnection(client); + + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); + +describe('subscription lifecycle', () => { + it('logs disconnects and emits an in-flight progress snapshot on subscribe', () => { + const { gateway, scanJobStore } = makeGateway(); + scanJobStore.get.mockReturnValue({ + jobId: 9, + processed: 50, + total: 200, + added: 10, + updated: 5, + missing: 2, + }); + const join = vi.fn(); + const emit = vi.fn(); + const client = { join, emit } as any; + + gateway.handleSubscribeLibrary(client, 88); + gateway.handleDisconnect({ id: 'sock-3' } as any); + + expect(join).toHaveBeenCalledWith('library:88'); + expect(emit).toHaveBeenCalledWith('scan:progress', { + jobId: 9, + libraryId: 88, + status: 'running', + processed: 50, + total: 200, + added: 10, + updated: 5, + missing: 2, + }); + }); + + it('subscribes without emitting progress when no scan entry exists', () => { + const { gateway, scanJobStore } = makeGateway(); + scanJobStore.get.mockReturnValue(undefined); + const join = vi.fn(); + const emit = vi.fn(); + const client = { join, emit } as any; + + gateway.handleSubscribeLibrary(client, 7); + + expect(join).toHaveBeenCalledWith('library:7'); + expect(emit).not.toHaveBeenCalled(); + }); +}); + +describe('remaining emit methods', () => { + it('emits cover refresh progress and books added events', () => { + const { gateway } = makeGateway(); + const { server, to, emit } = mockServer(); + gateway['server'] = server as any; + + gateway.emitCoverRefreshProgress({ libraryId: 6, processed: 1, total: 3, status: 'running' }); + gateway.emitBooksAdded({ libraryId: 6, books: [{ id: 10 }] as any }); + + expect(to).toHaveBeenCalledWith('library:6'); + expect(emit).toHaveBeenCalledWith('cover:refresh:progress', { libraryId: 6, processed: 1, total: 3, status: 'running' }); + expect(emit).toHaveBeenCalledWith('scan:books:added', { libraryId: 6, books: [{ id: 10 }] }); + }); + + it('emits restored and moved book notifications to the library room', () => { + const { gateway } = makeGateway(); + const { server, to, emit } = mockServer(); + gateway['server'] = server as any; + + gateway.emitBookRestored({ libraryId: 3, bookIds: [2, 4] }); + gateway.emitBookMoved({ libraryId: 3, bookIds: [2, 4] }); + + expect(to).toHaveBeenCalledWith('library:3'); + expect(emit).toHaveBeenCalledWith('book:restored', { libraryId: 3, bookIds: [2, 4] }); + expect(emit).toHaveBeenCalledWith('book:moved', { libraryId: 3, bookIds: [2, 4] }); + }); +}); diff --git a/server/src/modules/scanner/scanner.controller.test.ts b/server/src/modules/scanner/scanner.controller.test.ts new file mode 100644 index 000000000..4bc2b49b6 --- /dev/null +++ b/server/src/modules/scanner/scanner.controller.test.ts @@ -0,0 +1,21 @@ +import { ScannerController } from './scanner.controller'; + +describe('ScannerController', () => { + it('delegates scan requests to ScannerService.startScan with manual trigger', () => { + const scannerService = { startScan: vi.fn().mockResolvedValue({ jobId: 11 }), refreshCovers: vi.fn() } as any; + const controller = new ScannerController(scannerService); + + void controller.scan(42); + + expect(scannerService.startScan).toHaveBeenCalledWith(42, 'manual'); + }); + + it('delegates cover refresh requests to ScannerService.refreshCovers', () => { + const scannerService = { startScan: vi.fn(), refreshCovers: vi.fn().mockResolvedValue({ queued: 3 }) } as any; + const controller = new ScannerController(scannerService); + + void controller.refreshCovers(9); + + expect(scannerService.refreshCovers).toHaveBeenCalledWith(9); + }); +}); diff --git a/server/src/modules/scanner/scanner.module.test.ts b/server/src/modules/scanner/scanner.module.test.ts new file mode 100644 index 000000000..0a0395f03 --- /dev/null +++ b/server/src/modules/scanner/scanner.module.test.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata'; + +vi.mock('../auth/auth.module', () => ({ AuthModule: class AuthModule {} })); +vi.mock('../book-metadata-fetch/book-metadata-fetch.module', () => ({ BookMetadataFetchModule: class BookMetadataFetchModule {} })); +vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} })); + +import { AuthModule } from '../auth/auth.module'; +import { BookMetadataFetchModule } from '../book-metadata-fetch/book-metadata-fetch.module'; +import { MetadataModule } from '../metadata/metadata.module'; +import { FileEventProcessorService } from './file-event-processor.service'; +import { FileWatcherService } from './file-watcher.service'; +import { ScanGateway } from './scan.gateway'; +import { ScanJobStore } from './scan-job-store.service'; +import { ScannerController } from './scanner.controller'; +import { ScannerModule } from './scanner.module'; +import { ScannerRepository } from './scanner.repository'; +import { ScannerService } from './scanner.service'; + +describe('ScannerModule', () => { + it('registers expected imports/controllers/providers/exports', () => { + const imports = Reflect.getMetadata('imports', ScannerModule) as unknown[]; + expect(imports[0]).toBe(MetadataModule); + expect(imports[1]).toBe(AuthModule); + expect(imports[2]).toEqual(expect.objectContaining({ forwardRef: expect.any(Function) })); + expect(imports[3]).toEqual(expect.objectContaining({ module: expect.any(Function) })); + + expect(Reflect.getMetadata('controllers', ScannerModule)).toEqual([ScannerController]); + expect(Reflect.getMetadata('providers', ScannerModule)).toEqual([ + ScannerService, + ScannerRepository, + ScanGateway, + ScanJobStore, + FileEventProcessorService, + FileWatcherService, + ]); + expect(Reflect.getMetadata('exports', ScannerModule)).toEqual([ScannerService, FileWatcherService, ScanGateway]); + }); + + it('wires BookMetadataFetchModule via forwardRef to avoid circular import issues', () => { + const imports = Reflect.getMetadata('imports', ScannerModule) as Array<{ forwardRef?: () => unknown }>; + const forwardRefImport = imports.find((entry) => typeof entry?.forwardRef === 'function'); + expect(forwardRefImport?.forwardRef?.()).toBe(BookMetadataFetchModule); + }); +}); diff --git a/server/src/modules/scanner/scanner.repository.test.ts b/server/src/modules/scanner/scanner.repository.test.ts new file mode 100644 index 000000000..4cd50d404 --- /dev/null +++ b/server/src/modules/scanner/scanner.repository.test.ts @@ -0,0 +1,249 @@ +import { ScannerRepository } from './scanner.repository'; + +type QueryKind = 'select' | 'insert' | 'update' | 'delete'; + +type ChainState = { + proxy: any; + mocks: Record; +}; + +function makeChain(initialResult: unknown): ChainState { + let result = initialResult; + const mocks: Record = {}; + const proxy = new Proxy( + {}, + { + get(_target, key) { + if (key === 'then') { + return (onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) => + Promise.resolve(result).then(onFulfilled, onRejected); + } + if (key === '__setResult') { + return (next: unknown) => { + result = next; + }; + } + const method = String(key); + if (!mocks[method]) { + mocks[method] = vi.fn(() => proxy); + } + return mocks[method]; + }, + }, + ); + + return { proxy, mocks }; +} + +function makeDb() { + const queues: Record = { select: [], insert: [], update: [], delete: [] }; + const chains: Record = { select: [], insert: [], update: [], delete: [] }; + + const next = (kind: QueryKind) => { + const state = makeChain(queues[kind].length > 0 ? queues[kind].shift() : []); + chains[kind].push(state); + return state.proxy; + }; + + return { + queues, + chains, + db: { + select: vi.fn(() => next('select')), + insert: vi.fn(() => next('insert')), + update: vi.fn(() => next('update')), + delete: vi.fn(() => next('delete')), + }, + }; +} + +function makeRepo() { + const fixture = makeDb(); + return { ...fixture, repo: new ScannerRepository(fixture.db as any) }; +} + +describe('ScannerRepository', () => { + it('handles scan job lifecycle writes', async () => { + const { repo, queues, db, chains } = makeRepo(); + queues.insert.push([{ id: 501, libraryId: 3, triggeredBy: 'manual' }]); + + const job = await repo.createScanJob(3, 'manual'); + await repo.completeScanJob(501, { addedCount: 1, updatedCount: 2, missingCount: 3 }); + await repo.failScanJob(501, 'failed'); + await repo.failAllRunningJobs('restart'); + + expect(job).toEqual({ id: 501, libraryId: 3, triggeredBy: 'manual' }); + expect(db.update).toHaveBeenCalledTimes(3); + expect(chains.update[0].mocks.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', addedCount: 1 })); + expect(chains.update[1].mocks.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed', errorMessage: 'failed' })); + expect(chains.update[2].mocks.set).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed', errorMessage: 'restart' })); + }); + + it('reads library folders/settings and returns null when rows are absent', async () => { + const { repo, queues } = makeRepo(); + queues.select.push([{ id: 1, path: '/books' }]); + queues.select.push([{ allowedFormats: ['epub'], formatPriority: ['epub'], excludePatterns: [], organizationMode: 'book_per_folder' }]); + queues.select.push([]); + queues.select.push([{ path: '/books' }]); + queues.select.push([]); + + await expect(repo.findLibraryFolders(1)).resolves.toEqual([{ id: 1, path: '/books' }]); + await expect(repo.findLibrarySettings(1)).resolves.toEqual({ + allowedFormats: ['epub'], + formatPriority: ['epub'], + excludePatterns: [], + organizationMode: 'book_per_folder', + }); + await expect(repo.findLibrarySettings(2)).resolves.toBeNull(); + await expect(repo.findLibraryFolderPath(1)).resolves.toBe('/books'); + await expect(repo.findLibraryFolderPath(2)).resolves.toBeNull(); + }); + + it('creates and updates books, including primary file reassignment and missing state', async () => { + const { repo, queues, db, chains } = makeRepo(); + queues.insert.push([{ id: 11, libraryId: 9, folderPath: '/books/Book', status: 'present' }]); + queues.insert.push([]); + + const created = await repo.createBook({ libraryId: 9, libraryFolderId: 3, folderPath: '/books/Book', status: 'present' } as any); + await repo.updateBookStatus(11, 'missing'); + await repo.updateBookPrimaryFile(11, 77); + await repo.updateBookPrimaryFile(11, null); + await repo.markBooksAsMissing([11, 12]); + + expect(created).toEqual({ id: 11, libraryId: 9, folderPath: '/books/Book', status: 'present' }); + expect(db.insert).toHaveBeenCalledTimes(2); + expect(chains.insert[0].mocks.onConflictDoUpdate).toHaveBeenCalledTimes(1); + expect(chains.insert[1].mocks.onConflictDoNothing).toHaveBeenCalledTimes(1); + expect(db.update).toHaveBeenCalledTimes(4); + }); + + it('queries book/file lookups with optional scope variants', async () => { + const { repo, queues } = makeRepo(); + queues.select.push([{ id: 1, status: 'present', folderPath: '/books/A' }]); + queues.select.push([{ bookId: 1, absolutePath: '/books/A/book.epub', format: 'epub' }]); + queues.select.push([{ id: 7, absolutePath: '/books/A/book.epub' }]); + queues.select.push([{ id: 90, bookId: 1, absolutePath: '/books/A/book.epub', ino: 999, sizeBytes: 10, mtime: new Date(), hash: 'x' }]); + queues.select.push([{ id: 15, bookId: 1, libraryId: 4, primaryFileId: 90 }]); + queues.select.push([{ id: 16, bookId: 2, libraryId: 5, primaryFileId: 91 }]); + queues.select.push([{ id: 3, folderPath: '/books/A' }]); + queues.select.push([{ id: 4, folderPath: '/books/B' }]); + queues.select.push([{ id: 8, folderPath: '/books/A', status: 'missing' }]); + queues.select.push([]); + queues.select.push([{ id: 11, folderPath: '/books/A', status: 'missing' }]); + queues.select.push([{ id: 12, folderPath: '/books/B', status: 'missing' }]); + + await expect(repo.findBooksByLibraryFolder(4)).resolves.toEqual([{ id: 1, status: 'present', folderPath: '/books/A' }]); + await expect(repo.findPrimaryBookFilesByLibrary(4)).resolves.toEqual([{ bookId: 1, absolutePath: '/books/A/book.epub', format: 'epub' }]); + await expect(repo.findPrimaryBookFilesByBookId(1)).resolves.toEqual([{ id: 7, absolutePath: '/books/A/book.epub' }]); + await expect(repo.findBookFilesByLibraryFolder(4)).resolves.toEqual([ + { id: 90, bookId: 1, absolutePath: '/books/A/book.epub', ino: 999, sizeBytes: 10, mtime: expect.any(Date), hash: 'x' }, + ]); + await expect(repo.findBookFileByAbsolutePath('/books/A/book.epub')).resolves.toEqual({ id: 15, bookId: 1, libraryId: 4, primaryFileId: 90 }); + await expect(repo.findBookFileByAbsolutePath('/books/B/book.epub', 5)).resolves.toEqual({ id: 16, bookId: 2, libraryId: 5, primaryFileId: 91 }); + await expect(repo.findBooksByFolderPath('/books/A')).resolves.toEqual([{ id: 3, folderPath: '/books/A' }]); + await expect(repo.findBooksByFolderPath('/books/B', 5)).resolves.toEqual([{ id: 4, folderPath: '/books/B' }]); + await expect(repo.findMissingBookByFolderPath('/books/A')).resolves.toEqual({ id: 8, folderPath: '/books/A', status: 'missing' }); + await expect(repo.findMissingBookByFolderPath('/books/B', 5)).resolves.toBeNull(); + await expect(repo.findMissingBooksByFolderPath('/books/A')).resolves.toEqual([{ id: 11, folderPath: '/books/A', status: 'missing' }]); + await expect(repo.findMissingBooksByFolderPath('/books/B', 5)).resolves.toEqual([{ id: 12, folderPath: '/books/B', status: 'missing' }]); + }); + + it('creates and updates book files and supports hash lookup', async () => { + const { repo, queues, chains, db } = makeRepo(); + queues.select.push([{ id: 50, hash: 'abc123' }]); + queues.insert.push([{ id: 300, absolutePath: '/books/C/book.epub', hash: 'abc123' }]); + queues.update.push([{ id: 300, absolutePath: '/books/C/book.epub', hash: 'abc123', format: 'epub' }]); + + await expect(repo.findBookFileByHash('abc123', 9)).resolves.toEqual({ id: 50, hash: 'abc123' }); + await expect(repo.createBookFile({ bookId: 1, libraryFolderId: 9, absolutePath: '/books/C/book.epub' } as any)).resolves.toEqual({ + id: 300, + absolutePath: '/books/C/book.epub', + hash: 'abc123', + }); + await expect(repo.updateBookFile(300, { format: 'epub' } as any)).resolves.toEqual({ + id: 300, + absolutePath: '/books/C/book.epub', + hash: 'abc123', + format: 'epub', + }); + expect(chains.update[0].mocks.set).toHaveBeenCalledWith(expect.objectContaining({ format: 'epub' })); + expect(db.insert).toHaveBeenCalledTimes(1); + }); + + it('handles missing/present transitions and moving books across libraries', async () => { + const { repo, queues, db } = makeRepo(); + queues.select.push([ + { id: 1, status: 'missing' }, + { id: 2, status: 'missing' }, + ]); + queues.update.push([]); + queues.update.push([{ id: 99, libraryId: 7, libraryFolderId: 8, folderPath: '/dest/Book', status: 'present' }]); + queues.update.push([]); + + await expect(repo.findMissingBooksForLibraries([7, 8])).resolves.toEqual([ + { id: 1, status: 'missing' }, + { id: 2, status: 'missing' }, + ]); + await repo.markBooksAsPresent([1, 2]); + await expect(repo.moveBookToLibrary(99, 7, 8, '/dest/Book')).resolves.toEqual({ + id: 99, + libraryId: 7, + libraryFolderId: 8, + folderPath: '/dest/Book', + status: 'present', + }); + await expect(repo.moveBookToLibrary(100, 7, 8, '/dest/Missing')).resolves.toBeNull(); + + expect(db.update).toHaveBeenCalledTimes(3); + }); + + it('supports context lookups by inode/hash and file deletion', async () => { + const { repo, queues, db } = makeRepo(); + queues.select.push([{ id: 1, bookId: 1 }]); + queues.select.push([{ id: 2, bookId: 2 }]); + queues.select.push([{ id: 10, file: { id: 10, ino: 123 } }]); + queues.select.push([{ id: 11, file: { id: 11, ino: 124 } }]); + queues.select.push([{ id: 12, file: { id: 12, hash: 'x' } }]); + queues.select.push([{ id: 13, file: { id: 13, hash: 'y' } }]); + queues.select.push([{ id: 14, file: { id: 14, hash: 'z' } }]); + queues.select.push([{ id: 15, file: { id: 15, hash: 'zz' } }]); + queues.select.push([{ id: 20, bookId: 4, absolutePath: '/books/D/book.epub' }]); + queues.select.push([{ id: 21, bookId: 5, absolutePath: '/books/E/book.epub' }]); + + await expect(repo.findBookFilesByBookId(1)).resolves.toEqual([{ id: 1, bookId: 1 }]); + await expect(repo.findBookFilesByBookIds([1, 2])).resolves.toEqual([{ id: 2, bookId: 2 }]); + await expect(repo.findBookFileWithContextByIno(123)).resolves.toEqual({ id: 10, file: { id: 10, ino: 123 } }); + await expect(repo.findBookFileWithContextByIno(124, 4)).resolves.toEqual({ id: 11, file: { id: 11, ino: 124 } }); + await expect(repo.findMissingBookFileWithContextByIno(125)).resolves.toEqual({ id: 12, file: { id: 12, hash: 'x' } }); + await expect(repo.findBookFileWithContextByHash('abc')).resolves.toEqual({ id: 13, file: { id: 13, hash: 'y' } }); + await expect(repo.findMissingBookFileWithContextByHash('def')).resolves.toEqual({ id: 14, file: { id: 14, hash: 'z' } }); + await expect(repo.findBookFileByAbsolutePath('/books/D/book.epub', 4)).resolves.toEqual({ + id: 15, + file: { id: 15, hash: 'zz' }, + }); + await expect(repo.findBookFilesByBookId(4)).resolves.toEqual([{ id: 20, bookId: 4, absolutePath: '/books/D/book.epub' }]); + await expect(repo.findBookFilesByBookIds([4, 5])).resolves.toEqual([{ id: 21, bookId: 5, absolutePath: '/books/E/book.epub' }]); + await repo.deleteBookFile(21); + await repo.updateBookFolderPath(4, '/books/D'); + + expect(db.delete).toHaveBeenCalledTimes(1); + expect(db.update).toHaveBeenCalledTimes(1); + }); + + it('returns empty card data for empty input and aggregates card data for requested books', async () => { + const { repo, queues } = makeRepo(); + await expect(repo.findBookCardData([])).resolves.toEqual({ rows: [], authorRows: [], fileRows: [], genreRows: [] }); + + queues.select.push([{ id: 1, title: 'Book One' }]); + queues.select.push([{ bookId: 1, name: 'Author One' }]); + queues.select.push([{ bookId: 1, id: 10, format: 'epub', role: 'content' }]); + queues.select.push([{ bookId: 1, name: 'Fiction' }]); + + await expect(repo.findBookCardData([1])).resolves.toEqual({ + rows: [{ id: 1, title: 'Book One' }], + authorRows: [{ bookId: 1, name: 'Author One' }], + fileRows: [{ bookId: 1, id: 10, format: 'epub', role: 'content' }], + genreRows: [{ bookId: 1, name: 'Fiction' }], + }); + }); +}); diff --git a/server/src/modules/scanner/scanner.service.test.ts b/server/src/modules/scanner/scanner.service.test.ts index f6a1ad26f..f5a442bd4 100644 --- a/server/src/modules/scanner/scanner.service.test.ts +++ b/server/src/modules/scanner/scanner.service.test.ts @@ -21,6 +21,7 @@ import { DEFAULT_FORMAT_PRIORITY } from './lib/classify'; import type { BookCandidate, FileStat } from './lib/walk'; import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk'; import { fingerprintFile } from './lib/hash'; +import * as assembleBookCardsModule from '../book/utils/assemble-book-cards'; const mockFindCandidates = findBookCandidates as MockedFunction; const mockFindLooseCandidates = findLooseFileCandidates as MockedFunction; @@ -115,6 +116,7 @@ const mockGateway = { emitBookMissing: vi.fn(), emitBookRestored: vi.fn(), emitBookMoved: vi.fn(), + emitBooksAdded: vi.fn(), emitCoverRefreshed: vi.fn(), emitCoverRefreshProgress: vi.fn(), }; @@ -1577,3 +1579,136 @@ describe('book_per_file mode — scanBookFolder', () => { expect(mockBuildSingleCandidate).toHaveBeenCalledWith('/library/Author/Book', '/library', expect.any(Array), expect.any(Function)); }); }); + +describe('bootstrap, wrappers, and cover refresh', () => { + it('marks running scan jobs as failed on application bootstrap', async () => { + const repo = makeRepo(); + const { service } = makeService(repo); + + await service.onApplicationBootstrap(); + + expect(repo.failAllRunningJobs).toHaveBeenCalledWith('Server restarted during scan'); + }); + + it('does not start another async scan when one is already running', () => { + const repo = makeRepo(); + const { service, jobStore } = makeService(repo); + const startScanSpy = vi.spyOn(service, 'startScan'); + jobStore.create(500, 1, 0); + + service.startScanAsync(1); + + expect(startScanSpy).not.toHaveBeenCalled(); + }); + + it('logs async start failures from startScanAsync', async () => { + const repo = makeRepo(); + const { service } = makeService(repo); + vi.spyOn(service, 'startScan').mockRejectedValue(new Error('queue failed')); + + service.startScanAsync(4); + await Promise.resolve(); + + expect(Logger.prototype.error).toHaveBeenCalledWith(expect.stringContaining('[scanner.start_scan] [fail] libraryId=4')); + }); + + it('reports scan running state from ScanJobStore', () => { + const repo = makeRepo(); + const { service, jobStore } = makeService(repo); + + expect(service.isScanRunning(8)).toBe(false); + jobStore.create(900, 8, 0); + expect(service.isScanRunning(8)).toBe(true); + }); + + it('logs targeted scan failures from scanBookFolderAsync wrapper', async () => { + const repo = makeRepo(); + const { service } = makeService(repo); + vi.spyOn(service as any, 'scanBookFolder').mockRejectedValue(new Error('targeted scan failed')); + + service.scanBookFolderAsync('/library/Author/Book/book.epub', 1); + await Promise.resolve(); + + expect(Logger.prototype.error).toHaveBeenCalledWith(expect.stringContaining('[scanner.scan_book_folder] [fail]')); + }); + + it('refreshes covers in the background and emits progress/completion events', async () => { + const repo = makeRepo({ + findPrimaryBookFilesByLibrary: vi.fn().mockResolvedValue([ + { bookId: 1, absolutePath: '/library/Book/book.epub', format: 'epub' }, + { bookId: 2, absolutePath: '/library/Book/readme.txt', format: 'txt' }, + { bookId: 3, absolutePath: '/library/Book/book.pdf', format: 'pdf' }, + ]), + }); + mockMetadata.refreshCoverForBook.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + const { service } = makeService(repo); + + await expect(service.refreshCovers(1)).resolves.toEqual({ queued: 2 }); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockMetadata.refreshCoverForBook).toHaveBeenNthCalledWith(1, 1, '/library/Book/book.epub', 'epub'); + expect(mockMetadata.refreshCoverForBook).toHaveBeenNthCalledWith(2, 3, '/library/Book/book.pdf', 'pdf'); + expect(mockGateway.emitCoverRefreshed).toHaveBeenCalledWith({ bookId: 1, libraryId: 1 }); + expect(mockGateway.emitCoverRefreshProgress).toHaveBeenNthCalledWith(1, { libraryId: 1, processed: 0, total: 2, status: 'running' }); + expect(mockGateway.emitCoverRefreshProgress).toHaveBeenNthCalledWith(2, { libraryId: 1, processed: 1, total: 2, status: 'running' }); + expect(mockGateway.emitCoverRefreshProgress).toHaveBeenNthCalledWith(3, { libraryId: 1, processed: 2, total: 2, status: 'completed' }); + }); + + it('rethrows refreshCovers query failures', async () => { + const repo = makeRepo({ + findPrimaryBookFilesByLibrary: vi.fn().mockRejectedValue(new Error('lookup failed')), + }); + const { service } = makeService(repo); + + await expect(service.refreshCovers(9)).rejects.toThrow('lookup failed'); + expect(Logger.prototype.warn).toHaveBeenCalledWith(expect.stringContaining('[scanner.refresh_covers] [fail]')); + }); + + it('logs refreshCovers background failures without failing the initial request', async () => { + const repo = makeRepo({ + findPrimaryBookFilesByLibrary: vi.fn().mockResolvedValue([{ bookId: 7, absolutePath: '/library/Book/book.epub', format: 'epub' }]), + }); + mockMetadata.refreshCoverForBook.mockRejectedValueOnce(new Error('provider timeout')); + const { service } = makeService(repo); + + await expect(service.refreshCovers(5)).resolves.toEqual({ queued: 1 }); + await Promise.resolve(); + await Promise.resolve(); + + expect(Logger.prototype.warn).toHaveBeenCalledWith(expect.stringContaining('[scanner.refresh_covers] [fail]')); + }); + + it('flushes buffered added books immediately at threshold and emits built cards', async () => { + vi.useFakeTimers(); + const repo = makeRepo({ + findBookCardData: vi.fn().mockResolvedValue({ rows: [{ id: 1 }], authorRows: [], fileRows: [], genreRows: [] }), + }); + const assembleSpy = vi.spyOn(assembleBookCardsModule, 'assembleBookCards').mockReturnValue([{ id: 1 }] as any); + const { service } = makeService(repo); + const flushSpy = vi.spyOn(service as any, 'flushBookEmitBuffer'); + + for (let i = 0; i < 20; i++) { + (service as any).bufferBookForEmit(3, i + 1); + } + + expect(flushSpy).toHaveBeenCalledWith(3); + await (service as any).buildAndEmitBookCards(3, [1]); + expect(mockGateway.emitBooksAdded).toHaveBeenCalledWith({ libraryId: 3, books: [{ id: 1 }] }); + expect(assembleSpy).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('schedules a delayed book buffer flush before the threshold is reached', () => { + vi.useFakeTimers(); + const repo = makeRepo(); + const { service } = makeService(repo); + const flushSpy = vi.spyOn(service as any, 'flushBookEmitBuffer').mockImplementation(() => undefined); + + (service as any).bufferBookForEmit(6, 42); + vi.runOnlyPendingTimers(); + + expect(flushSpy).toHaveBeenCalledWith(6); + vi.useRealTimers(); + }); +}); diff --git a/server/src/modules/statistics/statistics.controller.test.ts b/server/src/modules/statistics/statistics.controller.test.ts new file mode 100644 index 000000000..6033113fd --- /dev/null +++ b/server/src/modules/statistics/statistics.controller.test.ts @@ -0,0 +1,67 @@ +import { StatisticsController } from './statistics.controller'; + +describe('StatisticsController', () => { + const service = { + getFormatDistribution: vi.fn(), + getLanguageDistribution: vi.fn(), + getBooksAddedOverTime: vi.fn(), + getStorageByFormat: vi.fn(), + getMetadataScoreDistribution: vi.fn(), + getLibraryMetadataCompleteness: vi.fn(), + getFormatShareOverTime: vi.fn(), + getPageCountDistribution: vi.fn(), + getPublicationDecade: vi.fn(), + getPublicationYearTimeline: vi.fn(), + getTopAuthors: vi.fn(), + getMetadataCompleteness: vi.fn(), + getGenreDistribution: vi.fn(), + getSummary: vi.fn(), + getGenreCooccurrence: vi.fn(), + getMetadataFreshnessGauge: vi.fn(), + getLibraryIntegrityGauge: vi.fn(), + getAcquisitionLagScatter: vi.fn(), + getLargestBooks: vi.fn(), + getTopSeries: vi.fn(), + }; + + const controller = new StatisticsController(service as never); + const user = { id: 10 } as never; + const query = { libraryIds: [1, 2] } as never; + + beforeEach(() => { + vi.clearAllMocks(); + for (const fn of Object.values(service)) { + fn.mockResolvedValue({ ok: true }); + } + }); + + it('forwards every endpoint request to the matching service method with user and query', async () => { + const methodMap: Array<[keyof StatisticsController, keyof typeof service]> = [ + ['getFormatDistribution', 'getFormatDistribution'], + ['getLanguageDistribution', 'getLanguageDistribution'], + ['getBooksAddedOverTime', 'getBooksAddedOverTime'], + ['getStorageByFormat', 'getStorageByFormat'], + ['getMetadataScoreDistribution', 'getMetadataScoreDistribution'], + ['getLibraryMetadataCompleteness', 'getLibraryMetadataCompleteness'], + ['getFormatShareOverTime', 'getFormatShareOverTime'], + ['getPageCountDistribution', 'getPageCountDistribution'], + ['getPublicationDecade', 'getPublicationDecade'], + ['getPublicationYearTimeline', 'getPublicationYearTimeline'], + ['getTopAuthors', 'getTopAuthors'], + ['getMetadataCompleteness', 'getMetadataCompleteness'], + ['getGenreDistribution', 'getGenreDistribution'], + ['getSummary', 'getSummary'], + ['getGenreCooccurrence', 'getGenreCooccurrence'], + ['getMetadataFreshnessGauge', 'getMetadataFreshnessGauge'], + ['getLibraryIntegrityGauge', 'getLibraryIntegrityGauge'], + ['getAcquisitionLagScatter', 'getAcquisitionLagScatter'], + ['getLargestBooks', 'getLargestBooks'], + ['getTopSeries', 'getTopSeries'], + ]; + + for (const [controllerMethod, serviceMethod] of methodMap) { + await (controller[controllerMethod] as (u: unknown, q: unknown) => Promise)(user, query); + expect(service[serviceMethod]).toHaveBeenCalledWith(user, query); + } + }); +}); diff --git a/server/src/modules/statistics/statistics.module.test.ts b/server/src/modules/statistics/statistics.module.test.ts new file mode 100644 index 000000000..5524e818d --- /dev/null +++ b/server/src/modules/statistics/statistics.module.test.ts @@ -0,0 +1,15 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { StatisticsController } from './statistics.controller'; +import { StatisticsModule } from './statistics.module'; +import { StatisticsRepository } from './statistics.repository'; +import { StatisticsService } from './statistics.service'; + +describe('StatisticsModule', () => { + it('registers controller and providers', () => { + expect(Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, StatisticsModule)).toEqual([StatisticsController]); + expect(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, StatisticsModule)).toEqual([StatisticsService, StatisticsRepository]); + }); +}); diff --git a/server/src/modules/statistics/statistics.repository.test.ts b/server/src/modules/statistics/statistics.repository.test.ts new file mode 100644 index 000000000..2d69d331b --- /dev/null +++ b/server/src/modules/statistics/statistics.repository.test.ts @@ -0,0 +1,233 @@ +import { StatisticsRepository } from './statistics.repository'; + +function makeChain(result: unknown) { + const chain: Record = { + from: vi.fn(), + where: vi.fn(), + innerJoin: vi.fn(), + leftJoin: vi.fn(), + groupBy: vi.fn(), + orderBy: vi.fn(), + limit: vi.fn(), + offset: vi.fn(), + as: vi.fn(), + then: (onFulfilled: (value: unknown) => unknown, onRejected?: (error: unknown) => unknown) => + Promise.resolve(result).then(onFulfilled, onRejected), + catch: (onRejected: (error: unknown) => unknown) => Promise.resolve(result).catch(onRejected), + }; + + for (const key of ['from', 'where', 'innerJoin', 'leftJoin', 'groupBy', 'orderBy', 'limit', 'offset', 'as']) { + (chain[key] as ReturnType).mockReturnValue(chain); + } + + return chain; +} + +function makeDb(selectQueue: unknown[] = [], executeQueue: unknown[] = []) { + const selects = [...selectQueue]; + const executes = [...executeQueue]; + + return { + select: vi.fn(() => makeChain(selects.shift() ?? [])), + execute: vi.fn(() => Promise.resolve({ rows: executes.shift() ?? [] })), + }; +} + +describe('StatisticsRepository', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('handles helper library-scope methods for superusers and restricted users', async () => { + const db = makeDb([[{ libraryId: 1 }, { libraryId: 3 }]]); + const repo = new StatisticsRepository(db as never); + + await expect((repo as any).getAccessibleLibraryIds(2, true)).resolves.toBeNull(); + await expect((repo as any).getAccessibleLibraryIds(2, false)).resolves.toEqual([1, 3]); + + expect((repo as any).intersectLibraryIds(null, undefined)).toBeNull(); + expect((repo as any).intersectLibraryIds(null, [5, 6])).toEqual([5, 6]); + expect((repo as any).intersectLibraryIds([1, 2, 3], [2, 8])).toEqual([2]); + + expect((repo as any).libraryFilter(null)).toBeUndefined(); + expect((repo as any).libraryFilter([])).toBeDefined(); + expect((repo as any).libraryFilter([1])).toBeDefined(); + }); + + it('executes all public statistic queries with expected return shaping', async () => { + const db = makeDb( + [ + [{ format: 'epub', count: 4 }], + [{ language: 'en', count: 8 }], + [{ unknownCount: 2 }], + [{ year: 2026, month: 0, count: 5 }], + [{ year: 2026, month: 4, count: 3 }], + [{ minScore: 20, count: 7 }], + [{ unknownCount: 1 }], + [{ totalCount: 7, percentile25: 10, percentile50: 20, percentile75: 30, percentile90: 40 }], + [ + { + libraryId: 1, + libraryName: 'Main', + total: 10, + hasTitle: 10, + hasCover: 8, + hasAuthor: 7, + hasGenre: 6, + hasTag: 5, + hasDescription: 4, + hasPublisher: 3, + hasYear: 2, + hasLanguage: 1, + hasPageCount: 9, + hasRating: 8, + hasSeries: 7, + hasIsbn: 6, + }, + ], + [{ year: 2026, month: 1, format: 'epub', count: 10 }], + [{ format: 'epub', count: 2, min: 50, q1: 100, median: 200, q3: 300, max: 500 }], + [{ unknownCount: 6 }], + [{ format: 'epub', sizeBytes: 1024 }], + [{ year: 2020, count: 4, topTitles: ['A'] }], + [{ year: 2020, title: 'A' }], + [{ unknownCount: 1 }], + [{ decade: 1990, count: 2 }], + [{ unknownCount: 3 }], + [{ name: 'Author A', count: 11 }], + [ + { + total: 10, + hasTitle: 10, + hasCover: 9, + hasDescription: 8, + hasPublisher: 7, + hasYear: 6, + hasLanguage: 5, + hasPageCount: 4, + hasRating: 3, + hasSeries: 2, + hasIsbn: 1, + hasAuthor: 9, + }, + ], + [{ genre: 'Sci-Fi', count: 9 }], + [{ unknownCount: 4 }], + [{ count: 12 }], + [{ count: 8 }], + [{ count: 4 }], + [{ count: 3 }], + [{ total: 1024 }], + [{ count: 6 }], + [{ count: 5 }], + [{ minYear: 1950, maxYear: 2020 }], + [{ count: 2 }], + [{ id: 1, name: 'Sci-Fi' }], + [{ totalBooks: 100, neverFetchedCount: 10, fresh30dCount: 40, stale31To90dCount: 20, stale91To180dCount: 15, staleOver180dCount: 15 }], + [{ totalBooks: 100, presentCount: 98, primaryFileCount: 97, metadataCount: 96 }], + [{ addedYear: 2026, lagYears: 4, count: 9 }], + [{ unknownCount: 1 }], + [{ id: 3, title: 'Big Book', sizeBytes: 9999, format: 'epub' }], + [{ name: 'Saga', count: 4 }], + [ + { id: 1, name: 'Sci-Fi' }, + { id: 2, name: 'Fantasy' }, + ], + ], + [[{ source: 'Sci-Fi', target: 'Fantasy', value: 3 }]], + ); + const repo = new StatisticsRepository(db as never); + + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([1, 2, 3]); + vi.spyOn(repo as any, 'resolveLibraryFilter').mockResolvedValue({ op: 'filter' }); + + await expect(repo.formatDistribution(1, false, [1])).resolves.toEqual([{ format: 'epub', count: 4 }]); + await expect(repo.languageDistribution(1, false, [1])).resolves.toEqual({ + items: [{ language: 'en', count: 8 }], + unknownCount: 2, + }); + + await expect(repo.booksAddedOverTime(1, false, [1], 'yearly', 'last-year')).resolves.toEqual([{ year: 2026, month: 0, count: 5 }]); + await expect(repo.booksAddedOverTime(1, false, [1], 'monthly', 'all-time')).resolves.toEqual([{ year: 2026, month: 4, count: 3 }]); + + await expect(repo.metadataScoreDistribution(1, false, [1])).resolves.toEqual({ + bins: [{ minScore: 20, count: 7 }], + unknownCount: 1, + totalCount: 7, + percentile25: 10, + percentile50: 20, + percentile75: 30, + percentile90: 40, + }); + + await expect(repo.libraryMetadataCompleteness(1, false, [1])).resolves.toHaveLength(1); + await expect(repo.formatShareOverTime(1, false, [1])).resolves.toEqual([{ year: 2026, month: 1, format: 'epub', count: 10 }]); + await expect(repo.pageCountDistributionByFormat(1, false, [1])).resolves.toEqual({ + items: [{ format: 'epub', count: 2, min: 50, q1: 100, median: 200, q3: 300, max: 500 }], + unknownCount: 6, + }); + await expect(repo.storageByFormat(1, false, [1])).resolves.toEqual([{ format: 'epub', sizeBytes: 1024 }]); + + const publicationTimeline = await repo.publicationYearTimeline(1, false, [1]); + expect(publicationTimeline.items).toEqual([ + expect.objectContaining({ + year: 2020, + }), + ]); + const publicationDecade = await repo.publicationDecade(1, false, [1]); + expect(publicationDecade.items).toHaveLength(1); + await expect(repo.topAuthors(1, false, [1])).resolves.toEqual(expect.any(Array)); + + const metadataCompleteness = await repo.metadataCompleteness(1, false, [1]); + expect(metadataCompleteness).toBeDefined(); + + const genreDistribution = await repo.genreDistribution(1, false, [1]); + expect(genreDistribution).toHaveProperty('items'); + expect(genreDistribution).toHaveProperty('unknownCount'); + + const summary = await repo.getSummary(1, false, [1]); + expect(summary).toBeDefined(); + + const freshness = await repo.metadataFreshnessGauge(1, false, [1]); + expect(freshness).toHaveProperty('totalBooks'); + + const integrity = await repo.libraryIntegrityGauge(1, false, [1]); + expect(integrity).toHaveProperty('totalBooks'); + + const lagScatter = await repo.acquisitionLagScatter(1, false, [1]); + expect(lagScatter).toHaveProperty('items'); + + await expect(repo.largestBooks(1, false, [1])).resolves.toEqual(expect.any(Array)); + await expect(repo.topSeries(1, false, [1])).resolves.toEqual(expect.any(Array)); + + const cooccurrence = await repo.getGenreCooccurrence(1, false, [1], 15); + expect(cooccurrence).toHaveProperty('nodes'); + expect(cooccurrence).toHaveProperty('links'); + }); + + it('returns fallback structures when optional query rows are missing', async () => { + const db = makeDb([[{ id: 1, name: 'Only one' }], []]); + const repo = new StatisticsRepository(db as never); + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([1]); + vi.spyOn(repo as any, 'resolveLibraryFilter').mockResolvedValue({ op: 'filter' }); + + await expect(repo.getGenreCooccurrence(1, false, [1], 15)).resolves.toEqual({ + nodes: [{ name: 'Only one' }], + links: [], + }); + await expect(repo.metadataCompleteness(1, false, [1])).resolves.toEqual({ + total: 0, + hasTitle: 0, + hasCover: 0, + hasDescription: 0, + hasPublisher: 0, + hasYear: 0, + hasLanguage: 0, + hasPageCount: 0, + hasRating: 0, + hasSeries: 0, + hasIsbn: 0, + hasAuthor: 0, + }); + }); +}); diff --git a/server/src/modules/statistics/statistics.service.additional.test.ts b/server/src/modules/statistics/statistics.service.additional.test.ts new file mode 100644 index 000000000..6738b2fe0 --- /dev/null +++ b/server/src/modules/statistics/statistics.service.additional.test.ts @@ -0,0 +1,183 @@ +import type { RequestUser } from '../../common/types/request-user'; +import { StatisticsService } from './statistics.service'; + +function makeUser(isSuperuser = false): RequestUser { + return { + id: 2, + username: 'reader', + name: 'Reader', + email: null, + active: true, + isSuperuser, + isDefaultPassword: false, + tokenVersion: 1, + settings: {}, + avatarUrl: null, + provisioningMethod: 'local', + permissions: [], + }; +} + +function makeService() { + const repo = { + formatDistribution: vi.fn(), + languageDistribution: vi.fn(), + booksAddedOverTime: vi.fn(), + metadataScoreDistribution: vi.fn(), + libraryMetadataCompleteness: vi.fn(), + formatShareOverTime: vi.fn(), + pageCountDistributionByFormat: vi.fn(), + storageByFormat: vi.fn(), + publicationDecade: vi.fn(), + publicationYearTimeline: vi.fn(), + topAuthors: vi.fn(), + metadataCompleteness: vi.fn(), + genreDistribution: vi.fn(), + metadataFreshnessGauge: vi.fn(), + libraryIntegrityGauge: vi.fn(), + acquisitionLagScatter: vi.fn(), + getSummary: vi.fn(), + getGenreCooccurrence: vi.fn(), + largestBooks: vi.fn(), + topSeries: vi.fn(), + }; + return { + repo, + service: new StatisticsService(repo as never), + }; +} + +describe('StatisticsService additional coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps language and page-count distribution payloads with normalization', async () => { + const { service, repo } = makeService(); + repo.languageDistribution.mockResolvedValue({ + items: [ + { language: 'en', count: 6 }, + { language: 'fr', count: 4 }, + ], + unknownCount: 3, + }); + repo.pageCountDistributionByFormat.mockResolvedValue({ + items: [{ format: 'epub', count: 1, min: 12, q1: '15.5', median: '21.2', q3: 30, max: 35 }], + unknownCount: 2, + }); + + await expect(service.getLanguageDistribution(makeUser(), {})).resolves.toEqual({ + items: [ + { language: 'en', count: 6 }, + { language: 'fr', count: 4 }, + ], + unknownCount: 3, + }); + + await expect(service.getPageCountDistribution(makeUser(), {})).resolves.toEqual({ + items: [{ format: 'EPUB', count: 1, min: 12, q1: 15.5, median: 21.2, q3: 30, max: 35 }], + unknownCount: 2, + }); + }); + + it('calculates metadata freshness and library integrity gauges with weighted scores', async () => { + const { service, repo } = makeService(); + repo.metadataFreshnessGauge.mockResolvedValue({ + totalBooks: 100, + neverFetchedCount: 10, + fresh30dCount: 50, + stale31To90dCount: 20, + stale91To180dCount: 20, + staleOver180dCount: 10, + }); + repo.libraryIntegrityGauge.mockResolvedValue({ + totalBooks: 80, + presentCount: 72, + primaryFileCount: 76, + metadataCount: 64, + }); + + await expect(service.getMetadataFreshnessGauge(makeUser(), {})).resolves.toEqual({ + totalBooks: 100, + neverFetchedCount: 10, + fresh30dCount: 50, + stale31To90dCount: 20, + stale91To180dCount: 20, + staleOver180dCount: 10, + freshnessScore: 74, + }); + + await expect(service.getLibraryIntegrityGauge(makeUser(), {})).resolves.toEqual({ + totalBooks: 80, + presentCount: 72, + primaryFileCount: 76, + metadataCount: 64, + integrityScore: 88, + }); + }); + + it('delegates scatter, decade, timeline, author, and genre endpoints', async () => { + const { service, repo } = makeService(); + repo.publicationDecade.mockResolvedValue({ items: [{ decade: 2000, count: 2 }], unknownCount: 1 }); + repo.publicationYearTimeline.mockResolvedValue({ items: [{ year: 2020, count: 3, topTitles: ['A'] }], unknownCount: 2 }); + repo.topAuthors.mockResolvedValue([{ name: 'Author A', count: 5 }]); + repo.genreDistribution.mockResolvedValue({ items: [{ genre: 'Sci-Fi', count: 7 }], unknownCount: 3 }); + repo.acquisitionLagScatter.mockResolvedValue({ items: [{ addedYear: 2024, lagYears: 5, count: 3 }], unknownCount: 4 }); + + await expect(service.getPublicationDecade(makeUser(), {})).resolves.toEqual({ + items: [{ decade: 2000, count: 2 }], + unknownCount: 1, + }); + await expect(service.getPublicationYearTimeline(makeUser(), {})).resolves.toEqual({ + items: [{ year: 2020, count: 3, topTitles: ['A'] }], + unknownCount: 2, + }); + await expect(service.getTopAuthors(makeUser(), {})).resolves.toEqual({ + items: [{ name: 'Author A', count: 5 }], + unknownCount: 0, + }); + await expect(service.getGenreDistribution(makeUser(), {})).resolves.toEqual({ + items: [{ genre: 'Sci-Fi', count: 7 }], + unknownCount: 3, + }); + await expect(service.getAcquisitionLagScatter(makeUser(), {})).resolves.toEqual({ + items: [{ addedYear: 2024, lagYears: 5, count: 3 }], + unknownCount: 4, + }); + }); + + it('caches summary and genre-cooccurrence results for repeated identical requests', async () => { + const { service, repo } = makeService(); + repo.getSummary.mockResolvedValue({ totalBooks: 5 }); + repo.getGenreCooccurrence.mockResolvedValue({ nodes: [{ name: 'A' }], links: [] }); + + await service.getSummary(makeUser(), { libraryIds: [3, 1, 3] }); + await service.getSummary(makeUser(), { libraryIds: [1, 3] }); + await service.getGenreCooccurrence(makeUser(), { libraryIds: [1, 3] }); + await service.getGenreCooccurrence(makeUser(), { libraryIds: [3, 1] }); + + expect(repo.getSummary).toHaveBeenCalledTimes(1); + expect(repo.getGenreCooccurrence).toHaveBeenCalledTimes(1); + }); + + it('filters invalid entries for largest-books and top-series payloads', async () => { + const { service, repo } = makeService(); + repo.largestBooks.mockResolvedValue([ + { id: 1, title: null, sizeBytes: 123, format: 'epub' }, + { id: 2, title: 'Book', sizeBytes: 456, format: 'pdf' }, + ]); + repo.topSeries.mockResolvedValue([ + { name: null, count: 8 }, + { name: 'Saga', count: 7 }, + ]); + + await expect(service.getLargestBooks(makeUser(), {})).resolves.toEqual({ + items: [{ id: 2, title: 'Book', sizeBytes: 456, format: 'pdf' }], + unknownCount: 0, + }); + await expect(service.getTopSeries(makeUser(), {})).resolves.toEqual({ + items: [{ name: 'Saga', count: 7 }], + unknownCount: 0, + }); + }); +}); diff --git a/server/src/modules/user-statistics/dto/user-statistics-dtos.test.ts b/server/src/modules/user-statistics/dto/user-statistics-dtos.test.ts new file mode 100644 index 000000000..c14758041 --- /dev/null +++ b/server/src/modules/user-statistics/dto/user-statistics-dtos.test.ts @@ -0,0 +1,74 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { UpdateUserSessionTimelineSessionDto } from './update-user-session-timeline-session.dto'; +import { UserDailyReadingQueryDto } from './user-daily-reading-query.dto'; +import { UserGoalTrajectoryQueryDto } from './user-goal-trajectory-query.dto'; +import { UserSessionTimelineQueryDto } from './user-session-timeline-query.dto'; +import { UserStatisticsFilterQueryDto } from './user-statistics-filter-query.dto'; + +describe('User statistics DTOs', () => { + it('transforms and validates library id filters', async () => { + const dto = plainToInstance(UserStatisticsFilterQueryDto, { libraryIds: '7' }); + + expect(dto.libraryIds).toEqual([7]); + expect(await validate(dto)).toEqual([]); + }); + + it('transforms comparePrevious flag and validates day bounds', async () => { + const valid = plainToInstance(UserDailyReadingQueryDto, { + libraryIds: ['1', '3'], + comparePrevious: 'yes', + days: '30', + }); + const invalid = plainToInstance(UserDailyReadingQueryDto, { + comparePrevious: 'true', + days: 0, + }); + + expect(valid.libraryIds).toEqual([1, 3]); + expect(valid.comparePrevious).toBe(true); + expect(await validate(valid)).toEqual([]); + expect((await validate(invalid)).length).toBeGreaterThan(0); + }); + + it('enforces goal trajectory and session timeline bounds', async () => { + const goal = plainToInstance(UserGoalTrajectoryQueryDto, { + days: 365, + goalBooks: 24, + }); + const badGoal = plainToInstance(UserGoalTrajectoryQueryDto, { goalBooks: 0 }); + + const timeline = plainToInstance(UserSessionTimelineQueryDto, { + year: 2026, + week: 12, + libraryIds: '9', + }); + const badTimeline = plainToInstance(UserSessionTimelineQueryDto, { + year: 1800, + week: 99, + }); + + expect(await validate(goal)).toEqual([]); + expect((await validate(badGoal)).length).toBeGreaterThan(0); + expect(timeline.libraryIds).toEqual([9]); + expect(await validate(timeline)).toEqual([]); + expect((await validate(badTimeline)).length).toBeGreaterThan(0); + }); + + it('requires ISO timestamps when updating timeline sessions', async () => { + const valid = plainToInstance(UpdateUserSessionTimelineSessionDto, { + startedAt: '2026-04-12T10:00:00.000Z', + endedAt: '2026-04-12T10:30:00.000Z', + }); + const invalid = plainToInstance(UpdateUserSessionTimelineSessionDto, { + startedAt: 'not-a-date', + endedAt: 'still-not-a-date', + }); + + expect(await validate(valid)).toEqual([]); + expect((await validate(invalid)).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/modules/user-statistics/user-statistics-aggregation.job.test.ts b/server/src/modules/user-statistics/user-statistics-aggregation.job.test.ts new file mode 100644 index 000000000..a8efbf685 --- /dev/null +++ b/server/src/modules/user-statistics/user-statistics-aggregation.job.test.ts @@ -0,0 +1,55 @@ +import { Logger } from '@nestjs/common'; + +import { UserStatisticsAggregationJob } from './user-statistics-aggregation.job'; + +describe('UserStatisticsAggregationJob', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('recomputes on bootstrap and logs when rows changed', async () => { + const service = { + recomputeRecentDailyStats: vi.fn().mockResolvedValue({ + deleted: 2, + inserted: 3, + since: '2026-04-09', + }), + }; + const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + + const job = new UserStatisticsAggregationJob(service as never); + await job.onApplicationBootstrap(); + + expect(service.recomputeRecentDailyStats).toHaveBeenCalledWith(2); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('deleted=2, inserted=3')); + }); + + it('runs hourly without chatty logs when no rows changed', async () => { + const service = { + recomputeRecentDailyStats: vi.fn().mockResolvedValue({ + deleted: 0, + inserted: 0, + since: '2026-04-09', + }), + }; + const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + + const job = new UserStatisticsAggregationJob(service as never); + await job.runHourlyAggregation(); + + expect(service.recomputeRecentDailyStats).toHaveBeenCalledWith(2); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('logs failures with root cause details', async () => { + const service = { + recomputeRecentDailyStats: vi.fn().mockRejectedValue(new Error('aggregation failed', { cause: new Error('db timeout') })), + }; + const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + + const job = new UserStatisticsAggregationJob(service as never); + await job.runHourlyAggregation(); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('db timeout'), expect.any(String)); + }); +}); diff --git a/server/src/modules/user-statistics/user-statistics.controller.test.ts b/server/src/modules/user-statistics/user-statistics.controller.test.ts new file mode 100644 index 000000000..f4c2e5715 --- /dev/null +++ b/server/src/modules/user-statistics/user-statistics.controller.test.ts @@ -0,0 +1,72 @@ +import { UserStatisticsController } from './user-statistics.controller'; + +function makeService() { + return { + getSummary: vi.fn().mockResolvedValue('summary'), + getDailyReading: vi.fn().mockResolvedValue('daily'), + getReadingHeatmap: vi.fn().mockResolvedValue('heatmap'), + getPeakReadingHours: vi.fn().mockResolvedValue('peak-hours'), + getFavoriteReadingDays: vi.fn().mockResolvedValue('favorite-days'), + getSessionTimeline: vi.fn().mockResolvedValue('timeline'), + updateSessionTimelineSession: vi.fn().mockResolvedValue('updated-session'), + getCompletionTimeline: vi.fn().mockResolvedValue('completion-timeline'), + getGoalTrajectory: vi.fn().mockResolvedValue('goal'), + getProgressFunnel: vi.fn().mockResolvedValue('funnel'), + getCompletionLatency: vi.fn().mockResolvedValue('latency'), + getGenreReadingTime: vi.fn().mockResolvedValue('genre-time'), + getReadingPace: vi.fn().mockResolvedValue('pace'), + getReadingSurvival: vi.fn().mockResolvedValue('survival'), + getCompletionRace: vi.fn().mockResolvedValue('race'), + getSessionArchetypes: vi.fn().mockResolvedValue('archetypes'), + getAuthorGenreChord: vi.fn().mockResolvedValue('chord'), + }; +} + +describe('UserStatisticsController', () => { + it('delegates each endpoint handler to UserStatisticsService', async () => { + const service = makeService(); + const controller = new UserStatisticsController(service as never); + const user = { id: 11, isSuperuser: false } as never; + const filter = { libraryIds: [1, 2] } as never; + const daily = { ...filter, days: 30 } as never; + const timeline = { ...filter, year: 2026, week: 10 } as never; + const goal = { ...daily, goalBooks: 18 } as never; + const updateDto = { startedAt: '2026-04-10T10:00:00.000Z', endedAt: '2026-04-10T10:30:00.000Z' } as never; + + await expect(controller.getSummary(user, filter)).resolves.toBe('summary'); + await expect(controller.getDailyReading(user, daily)).resolves.toBe('daily'); + await expect(controller.getReadingHeatmap(user, daily)).resolves.toBe('heatmap'); + await expect(controller.getPeakHours(user, daily)).resolves.toBe('peak-hours'); + await expect(controller.getFavoriteDays(user, daily)).resolves.toBe('favorite-days'); + await expect(controller.getSessionTimeline(user, timeline)).resolves.toBe('timeline'); + await expect(controller.updateSessionTimelineSession(user, 77, updateDto, filter)).resolves.toBe('updated-session'); + await expect(controller.getCompletionTimeline(user, daily)).resolves.toBe('completion-timeline'); + await expect(controller.getGoalTrajectory(user, goal)).resolves.toBe('goal'); + await expect(controller.getProgressFunnel(user, daily)).resolves.toBe('funnel'); + await expect(controller.getCompletionLatency(user, daily)).resolves.toBe('latency'); + await expect(controller.getGenreReadingTime(user, daily)).resolves.toBe('genre-time'); + await expect(controller.getReadingPace(user, daily)).resolves.toBe('pace'); + await expect(controller.getReadingSurvival(user, daily)).resolves.toBe('survival'); + await expect(controller.getCompletionRace(user, daily)).resolves.toBe('race'); + await expect(controller.getSessionArchetypes(user, daily)).resolves.toBe('archetypes'); + await expect(controller.getAuthorGenreChord(user, daily)).resolves.toBe('chord'); + + expect(service.getSummary).toHaveBeenCalledWith(user, filter); + expect(service.getDailyReading).toHaveBeenCalledWith(user, daily); + expect(service.getReadingHeatmap).toHaveBeenCalledWith(user, daily); + expect(service.getPeakReadingHours).toHaveBeenCalledWith(user, daily); + expect(service.getFavoriteReadingDays).toHaveBeenCalledWith(user, daily); + expect(service.getSessionTimeline).toHaveBeenCalledWith(user, timeline); + expect(service.updateSessionTimelineSession).toHaveBeenCalledWith(user, 77, updateDto, filter); + expect(service.getCompletionTimeline).toHaveBeenCalledWith(user, daily); + expect(service.getGoalTrajectory).toHaveBeenCalledWith(user, goal); + expect(service.getProgressFunnel).toHaveBeenCalledWith(user, daily); + expect(service.getCompletionLatency).toHaveBeenCalledWith(user, daily); + expect(service.getGenreReadingTime).toHaveBeenCalledWith(user, daily); + expect(service.getReadingPace).toHaveBeenCalledWith(user, daily); + expect(service.getReadingSurvival).toHaveBeenCalledWith(user, daily); + expect(service.getCompletionRace).toHaveBeenCalledWith(user, daily); + expect(service.getSessionArchetypes).toHaveBeenCalledWith(user, daily); + expect(service.getAuthorGenreChord).toHaveBeenCalledWith(user, daily); + }); +}); diff --git a/server/src/modules/user-statistics/user-statistics.module.test.ts b/server/src/modules/user-statistics/user-statistics.module.test.ts new file mode 100644 index 000000000..0c90c3f2d --- /dev/null +++ b/server/src/modules/user-statistics/user-statistics.module.test.ts @@ -0,0 +1,20 @@ +import 'reflect-metadata'; + +import { MODULE_METADATA } from '@nestjs/common/constants'; + +import { UserStatisticsAggregationJob } from './user-statistics-aggregation.job'; +import { UserStatisticsController } from './user-statistics.controller'; +import { UserStatisticsModule } from './user-statistics.module'; +import { UserStatisticsRepository } from './user-statistics.repository'; +import { UserStatisticsService } from './user-statistics.service'; + +describe('UserStatisticsModule', () => { + it('registers controller and providers', () => { + expect(Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, UserStatisticsModule)).toEqual([UserStatisticsController]); + expect(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, UserStatisticsModule)).toEqual([ + UserStatisticsService, + UserStatisticsRepository, + UserStatisticsAggregationJob, + ]); + }); +}); diff --git a/server/src/modules/user-statistics/user-statistics.repository.test.ts b/server/src/modules/user-statistics/user-statistics.repository.test.ts new file mode 100644 index 000000000..795ee2c52 --- /dev/null +++ b/server/src/modules/user-statistics/user-statistics.repository.test.ts @@ -0,0 +1,347 @@ +import { UserStatisticsRepository } from './user-statistics.repository'; + +function makeChain(result: unknown, fields?: Record) { + const chain: Record = {}; + for (const key of Object.keys(fields ?? {})) { + chain[key] = { key }; + } + + const methods = ['from', 'innerJoin', 'leftJoin', 'where', 'groupBy', 'orderBy', 'limit', 'offset', 'as'] as const; + for (const method of methods) { + chain[method] = vi.fn().mockReturnValue(chain); + } + + chain.then = (onFulfilled: (value: unknown) => unknown, onRejected?: (error: unknown) => unknown) => + Promise.resolve(result).then(onFulfilled, onRejected); + + return chain; +} + +function makeDb(selectQueue: unknown[] = [], executeQueue: unknown[] = []) { + const selects = [...selectQueue]; + const executes = [...executeQueue]; + + return { + select: vi.fn((fields?: Record) => makeChain(selects.shift() ?? [], fields)), + execute: vi.fn(() => { + const next = executes.shift(); + if (next && typeof next === 'object' && 'rows' in (next as Record)) { + return Promise.resolve(next); + } + return Promise.resolve({ rows: next ?? [] }); + }), + transaction: vi.fn(async (callback: (tx: { execute: ReturnType }) => Promise) => + callback({ + execute: vi.fn().mockResolvedValueOnce({ rowCount: 0 }).mockResolvedValueOnce({ rowCount: 0 }), + }), + ), + }; +} + +describe('UserStatisticsRepository', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-15T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('handles library scope helper methods and day helpers', async () => { + const db = makeDb([[{ libraryId: 1 }, { libraryId: 3 }]]); + const repo = new UserStatisticsRepository(db as never); + + await expect((repo as any).getAccessibleLibraryIds(9, true)).resolves.toBeNull(); + await expect((repo as any).getAccessibleLibraryIds(9, false)).resolves.toEqual([1, 3]); + + expect((repo as any).intersectLibraryIds(null, undefined)).toBeNull(); + expect((repo as any).intersectLibraryIds(null, [4, 5])).toEqual([4, 5]); + expect((repo as any).intersectLibraryIds([1, 2, 3], [2, 9])).toEqual([2]); + + expect((repo as any).libraryFilter(null)).toBeUndefined(); + expect((repo as any).libraryFilter([])).toBeDefined(); + expect((repo as any).dailyStatsLibraryFilter([2])).toBeDefined(); + expect((repo as any).formatDayKey(new Date('2026-04-15T00:00:00.000Z'))).toBe('2026-04-15'); + expect((repo as any).sinceDateForDays(3).toISOString()).toBe('2026-04-13T00:00:00.000Z'); + }); + + it('returns summary defaults when aggregate rows are missing', async () => { + const db = makeDb([[], [], []]); + const repo = new UserStatisticsRepository(db as never); + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([1]); + + await expect(repo.getSummary(5, false, [1])).resolves.toEqual({ + trackedBooks: 0, + startedBooks: 0, + inProgressBooks: 0, + completedBooks: 0, + meanProgressPercent: 0, + }); + }); + + it('returns daily/peak/favorite aggregates and monthly completion timeline', async () => { + const db = makeDb([ + [{ day: '2026-04-15', readingSeconds: 120, progressDelta: 1.5, eventsCount: 2 }], + [{ hour: 9, format: 'EPUB', readingSeconds: 500, eventsCount: 3 }], + [{ dayOfWeek: 2, readingSeconds: 900, eventsCount: 4 }], + [], + [{ year: 2026, month: 4, count: 2 }], + [], + [{ year: 2026, month: 4, count: 2 }], + ]); + const repo = new UserStatisticsRepository(db as never); + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([1, 2]); + + await expect(repo.getDailyReadingStats(5, false, [2], 30)).resolves.toEqual([ + { day: '2026-04-15', readingSeconds: 120, progressDelta: 1.5, eventsCount: 2 }, + ]); + await expect(repo.getPeakReadingHours(5, false, [2], 30)).resolves.toEqual([{ hour: 9, format: 'EPUB', readingSeconds: 500, eventsCount: 3 }]); + await expect(repo.getFavoriteReadingDays(5, false, [2], 30)).resolves.toEqual([{ dayOfWeek: 2, readingSeconds: 900, eventsCount: 4 }]); + await expect(repo.getCompletionTimeline(5, false, [2], 365)).resolves.toEqual([{ year: 2026, month: 4, count: 2 }]); + await expect(repo.getMonthlyCompletions(5, false, [2], 365)).resolves.toEqual([{ year: 2026, month: 4, count: 2 }]); + }); + + it('returns timeline items and timeline session by id', async () => { + const timelineRow = { + sessionId: 10, + bookId: 4, + bookTitle: 'Dune', + bookFormat: 'EPUB', + startedAt: new Date('2026-04-15T10:00:00.000Z'), + endedAt: new Date('2026-04-15T10:30:00.000Z'), + durationSeconds: 1800, + }; + const sessionRow = { + ...timelineRow, + libraryId: 3, + }; + const db = makeDb([[timelineRow], [sessionRow]]); + const repo = new UserStatisticsRepository(db as never); + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([3]); + + await expect( + repo.getSessionTimelineItems(5, false, [3], new Date('2026-04-15T00:00:00.000Z'), new Date('2026-04-16T00:00:00.000Z'), 100), + ).resolves.toEqual([ + { + sessionId: 10, + bookId: 4, + bookTitle: 'Dune', + bookFormat: 'EPUB', + startedAt: new Date('2026-04-15T10:00:00.000Z'), + endedAt: new Date('2026-04-15T10:30:00.000Z'), + durationSeconds: 1800, + }, + ]); + await expect(repo.getSessionTimelineSessionById(5, false, [3], 10)).resolves.toEqual(sessionRow); + }); + + it('returns conflict when moving timeline sessions into an overlap', async () => { + const txSelectQueue = [[{ sessionId: 88, startedAt: new Date('2026-04-15T10:10:00.000Z'), endedAt: new Date('2026-04-15T10:40:00.000Z') }]]; + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn((fields?: Record) => makeChain(txSelectQueue.shift() ?? [], fields)), + update: vi.fn(), + delete: vi.fn(), + }; + const db = { + transaction: vi.fn(async (callback: (trx: typeof tx) => Promise) => callback(tx)), + }; + const repo = new UserStatisticsRepository(db as never); + + const result = await repo.moveSessionTimelineSessionAtomic( + 5, + 10, + 3, + new Date('2026-04-15T09:00:00.000Z'), + new Date('2026-04-15T10:20:00.000Z'), + new Date('2026-04-15T10:50:00.000Z'), + 1800, + ); + + expect(result.updated).toBeNull(); + expect(result.conflict).toEqual({ + sessionId: 88, + startedAt: new Date('2026-04-15T10:10:00.000Z'), + endedAt: new Date('2026-04-15T10:40:00.000Z'), + }); + expect(tx.update).not.toHaveBeenCalled(); + }); + + it('returns null updated session when touched row disappears before update', async () => { + const txSelectQueue = [[]]; + const returning = vi.fn().mockResolvedValue([]); + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn((fields?: Record) => makeChain(txSelectQueue.shift() ?? [], fields)), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning, + }), + }), + }), + delete: vi.fn(), + }; + const db = { + transaction: vi.fn(async (callback: (trx: typeof tx) => Promise) => callback(tx)), + }; + const repo = new UserStatisticsRepository(db as never); + + const result = await repo.moveSessionTimelineSessionAtomic( + 5, + 10, + 3, + new Date('2026-04-15T09:00:00.000Z'), + new Date('2026-04-15T10:00:00.000Z'), + new Date('2026-04-15T10:30:00.000Z'), + 1800, + ); + + expect(result).toEqual({ updated: null, conflict: null }); + expect(tx.delete).not.toHaveBeenCalled(); + }); + + it('moves session atomically, refreshes affected daily stats, and returns updated row', async () => { + const updatedRow = { + sessionId: 10, + libraryId: 3, + bookId: 4, + bookTitle: 'Dune', + bookFormat: 'EPUB', + startedAt: new Date('2026-04-15T11:00:00.000Z'), + endedAt: new Date('2026-04-15T11:30:00.000Z'), + durationSeconds: 1800, + }; + const txSelectQueue = [[], [updatedRow]]; + const returning = vi.fn().mockResolvedValue([{ id: 10 }]); + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select: vi.fn((fields?: Record) => makeChain(txSelectQueue.shift() ?? [], fields)), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning, + }), + }), + }), + delete: vi.fn().mockReturnValue({ + where: deleteWhere, + }), + }; + const db = { + transaction: vi.fn(async (callback: (trx: typeof tx) => Promise) => callback(tx)), + }; + const repo = new UserStatisticsRepository(db as never); + + const result = await repo.moveSessionTimelineSessionAtomic( + 5, + 10, + 3, + new Date('2026-04-14T09:00:00.000Z'), + new Date('2026-04-15T11:00:00.000Z'), + new Date('2026-04-15T11:30:00.000Z'), + 1800, + ); + + expect(result).toEqual({ updated: updatedRow, conflict: null }); + expect(deleteWhere).toHaveBeenCalledOnce(); + expect(tx.execute).toHaveBeenCalledTimes(2); + }); + + it('returns progress funnel values and defaults when aggregate row is missing', async () => { + const db = makeDb([[], [{ started: 5, reached25: 4, reached50: 3, reached75: 2, completed: 1 }], [], []]); + const repo = new UserStatisticsRepository(db as never); + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([1]); + + await expect( + repo.getProgressFunnelInRange(5, false, [1], new Date('2026-04-01T00:00:00.000Z'), new Date('2026-05-01T00:00:00.000Z')), + ).resolves.toEqual({ + started: 5, + reached25: 4, + reached50: 3, + reached75: 2, + completed: 1, + }); + + await expect(repo.getProgressFunnelInRange(5, false, [1], new Date('2026-04-01T00:00:00.000Z'))).resolves.toEqual({ + started: 0, + reached25: 0, + reached50: 0, + reached75: 0, + completed: 0, + }); + }); + + it('normalizes completion latency, reading pace, survival, race, and archetype points', async () => { + const db = makeDb([ + [], + [], + [{ days: '3.5' }, { days: -1 }, { days: 'not-a-number' }, { days: 7 }], + [{ durationSeconds: 300, progressDelta: 1.25 }], + [], + [{ maxProgress: 50 }, { maxProgress: 'bad' }], + [{ bookId: 1 }], + [ + { bookId: 1, title: 'Dune', startedAt: new Date('2026-04-01T10:00:00.000Z'), endProgress: 40 }, + { bookId: 1, title: 'Dune', startedAt: new Date('2026-04-02T10:00:00.000Z'), endProgress: 70 }, + ], + [{ hour: '9.5', durationMinutes: '15', dayOfWeek: '2' }], + [{ genre: 'Sci-Fi', readingSeconds: 1200 }], + ]); + const repo = new UserStatisticsRepository(db as never); + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([1]); + + await expect(repo.getCompletionLatencyDays(5, false, [1], 365)).resolves.toEqual([3.5, 7]); + await expect(repo.getReadingPacePoints(5, false, [1], 365)).resolves.toEqual([{ durationSeconds: 300, progressDelta: 1.25 }]); + await expect(repo.getReadingSurvivalMaxProgress(5, false, [1], 365)).resolves.toEqual([50]); + await expect(repo.getCompletionRaceRawSessions(5, false, [1], 365, 15)).resolves.toEqual([ + { bookId: 1, title: 'Dune', startedAt: new Date('2026-04-01T10:00:00.000Z'), endProgress: 40 }, + { bookId: 1, title: 'Dune', startedAt: new Date('2026-04-02T10:00:00.000Z'), endProgress: 70 }, + ]); + await expect(repo.getSessionArchetypePoints(5, false, [1], 365)).resolves.toEqual([{ hour: 9.5, durationMinutes: 15, dayOfWeek: 2 }]); + await expect(repo.getGenreReadingTime(5, false, [1], 365)).resolves.toEqual([{ genre: 'Sci-Fi', readingSeconds: 1200 }]); + }); + + it('returns empty completion race when no top books exist', async () => { + const db = makeDb([[]]); + const repo = new UserStatisticsRepository(db as never); + vi.spyOn(repo as any, 'getAccessibleLibraryIds').mockResolvedValue([1]); + + await expect(repo.getCompletionRaceRawSessions(5, false, [1], 365, 15)).resolves.toEqual([]); + }); + + it('builds chord diagram nodes/links and returns empty chord when no rows exist', async () => { + const emptyDb = makeDb([], [{ rows: [] }]); + const emptyRepo = new UserStatisticsRepository(emptyDb as never); + vi.spyOn(emptyRepo as any, 'getAccessibleLibraryIds').mockResolvedValue([1]); + await expect(emptyRepo.getAuthorGenreChord(5, false, [1], 365)).resolves.toEqual({ nodes: [], links: [] }); + + const filledDb = makeDb([], [{ rows: [{ author: 'Author A', genre: 'Sci-Fi', reading_seconds: 600 }] }]); + const filledRepo = new UserStatisticsRepository(filledDb as never); + vi.spyOn(filledRepo as any, 'getAccessibleLibraryIds').mockResolvedValue([1]); + await expect(filledRepo.getAuthorGenreChord(5, false, [1], 365)).resolves.toEqual({ + nodes: [{ name: 'Author A' }, { name: 'Sci-Fi' }], + links: [{ source: 'Author A', target: 'Sci-Fi', value: 600 }], + }); + }); + + it('recomputes recent daily stats inside a transaction and returns row counts', async () => { + const db = { + transaction: vi.fn(async (callback: (tx: { execute: ReturnType }) => Promise) => + callback({ + execute: vi.fn().mockResolvedValueOnce({ rowCount: 4 }).mockResolvedValueOnce({ rowCount: 7 }), + }), + ), + }; + const repo = new UserStatisticsRepository(db as never); + + await expect(repo.recomputeRecentDailyStats(2)).resolves.toEqual({ + deleted: 4, + inserted: 7, + since: '2026-04-14', + }); + }); +}); diff --git a/server/src/modules/user-statistics/user-statistics.service.test.ts b/server/src/modules/user-statistics/user-statistics.service.test.ts index 5beb13e97..8b5b2a95e 100644 --- a/server/src/modules/user-statistics/user-statistics.service.test.ts +++ b/server/src/modules/user-statistics/user-statistics.service.test.ts @@ -390,4 +390,170 @@ describe('UserStatisticsService', () => { ).rejects.toThrow('duration cannot change'); expect(repo.moveSessionTimelineSessionAtomic).not.toHaveBeenCalled(); }); + + it('rejects invalid session timestamp payloads and missing sessions', async () => { + const repo = { + getSessionTimelineSessionById: vi.fn().mockResolvedValue(null), + moveSessionTimelineSessionAtomic: vi.fn(), + }; + + const service = new UserStatisticsService(repo as any); + await expect( + service.updateSessionTimelineSession( + { id: 123, isSuperuser: false } as any, + 52, + { + startedAt: 'not-a-date', + endedAt: '2026-04-07T10:36:00.000Z', + }, + { libraryIds: [5] }, + ), + ).rejects.toThrow('Invalid session timestamps'); + + await expect( + service.updateSessionTimelineSession( + { id: 123, isSuperuser: false } as any, + 52, + { + startedAt: '2026-04-07T10:36:00.000Z', + endedAt: '2026-04-07T10:35:00.000Z', + }, + { libraryIds: [5] }, + ), + ).rejects.toThrow('must be after start time'); + + await expect( + service.updateSessionTimelineSession( + { id: 123, isSuperuser: false } as any, + 52, + { + startedAt: '2026-04-07T10:00:00.000Z', + endedAt: '2026-04-07T10:30:00.000Z', + }, + { libraryIds: [5] }, + ), + ).rejects.toThrow('Reading session not found'); + }); + + it('rejects when atomic move reports missing updated row', async () => { + const existing = { + sessionId: 91, + libraryId: 5, + bookId: 90, + bookTitle: 'Ultralearning', + bookFormat: 'EPUB', + startedAt: new Date('2026-04-07T10:00:00.000Z'), + endedAt: new Date('2026-04-07T10:30:00.000Z'), + durationSeconds: 1800, + }; + const repo = { + getSessionTimelineSessionById: vi.fn().mockResolvedValue(existing), + moveSessionTimelineSessionAtomic: vi.fn().mockResolvedValue({ + updated: null, + conflict: null, + }), + }; + + const service = new UserStatisticsService(repo as any); + await expect( + service.updateSessionTimelineSession( + { id: 123, isSuperuser: false } as any, + 91, + { + startedAt: '2026-04-08T10:00:00.000Z', + endedAt: '2026-04-08T10:30:00.000Z', + }, + { libraryIds: [5] }, + ), + ).rejects.toThrow('Reading session not found'); + }); + + it('rounds progress deltas in daily reading and returns empty latency stats when no data', async () => { + const repo = { + getDailyReadingStats: vi.fn().mockResolvedValue([{ day: '2026-04-08', readingSeconds: 10, progressDelta: 1.234567, eventsCount: 1 }]), + getCompletionLatencyDays: vi.fn().mockResolvedValue([]), + }; + const service = new UserStatisticsService(repo as any); + + await expect(service.getDailyReading({ id: 11, isSuperuser: false } as any, { days: 1, libraryIds: [] })).resolves.toEqual([ + { day: '2026-04-08', readingSeconds: 10, progressDelta: 1.2346, eventsCount: 1 }, + ]); + await expect(service.getCompletionLatency({ id: 11, isSuperuser: false } as any, { days: 30, libraryIds: [] })).resolves.toEqual( + expect.objectContaining({ + totalCompletions: 0, + medianDays: null, + percentile75Days: null, + percentile90Days: null, + }), + ); + }); + + it('computes reading survival percentages and completion race trajectories', async () => { + const repo = { + getReadingSurvivalMaxProgress: vi.fn().mockResolvedValue([100, 65, 30]), + getCompletionRaceRawSessions: vi.fn().mockResolvedValue([ + { + bookId: 1, + title: 'A Very Long Book Title That Should Be Truncated For The Chart', + startedAt: new Date('2026-04-01T00:00:00.000Z'), + endProgress: 5.25, + }, + { + bookId: 1, + title: 'A Very Long Book Title That Should Be Truncated For The Chart', + startedAt: new Date('2026-04-03T00:00:00.000Z'), + endProgress: 45.51, + }, + { bookId: 2, title: 'Single Session', startedAt: new Date('2026-04-02T00:00:00.000Z'), endProgress: 90 }, + ]), + }; + const service = new UserStatisticsService(repo as any); + + const survival = await service.getReadingSurvival({ id: 11, isSuperuser: false } as any, { days: 365, libraryIds: [] }); + expect(survival.find((point) => point.threshold === 50)).toEqual({ + threshold: 50, + survivedCount: 2, + survivedPct: 66.7, + }); + + const race = await service.getCompletionRace({ id: 11, isSuperuser: false } as any, { days: 365, libraryIds: [] }); + expect(race).toHaveLength(1); + expect(race[0]).toEqual({ + bookId: 1, + title: 'A Very Long Book Title That Should Be...', + points: [ + { daysSinceStart: 0, progress: 5.3 }, + { daysSinceStart: 2, progress: 45.5 }, + ], + }); + }); + + it('delegates and caches archetype/genre/pace/chord queries', async () => { + const repo = { + getSessionArchetypePoints: vi.fn().mockResolvedValue([{ hour: 9, durationMinutes: 20, dayOfWeek: 2 }]), + getGenreReadingTime: vi.fn().mockResolvedValue([{ genre: 'Sci-Fi', readingSeconds: 300 }]), + getReadingPacePoints: vi.fn().mockResolvedValue([{ durationSeconds: 240, progressDelta: 1.4 }]), + getAuthorGenreChord: vi.fn().mockResolvedValue({ nodes: [{ name: 'A' }, { name: 'G' }], links: [{ source: 'A', target: 'G', value: 10 }] }), + }; + const service = new UserStatisticsService(repo as any); + const user = { id: 9, isSuperuser: false } as any; + + await expect(service.getSessionArchetypes(user, { libraryIds: [1] })).resolves.toEqual([{ hour: 9, durationMinutes: 20, dayOfWeek: 2 }]); + await expect(service.getGenreReadingTime(user, { libraryIds: [1] })).resolves.toEqual([{ genre: 'Sci-Fi', readingSeconds: 300 }]); + await expect(service.getReadingPace(user, { libraryIds: [1] })).resolves.toEqual([{ durationSeconds: 240, progressDelta: 1.4 }]); + await expect(service.getAuthorGenreChord(user, { libraryIds: [1] })).resolves.toEqual({ + nodes: [{ name: 'A' }, { name: 'G' }], + links: [{ source: 'A', target: 'G', value: 10 }], + }); + + await service.getSessionArchetypes(user, { libraryIds: [1] }); + await service.getGenreReadingTime(user, { libraryIds: [1] }); + await service.getReadingPace(user, { libraryIds: [1] }); + await service.getAuthorGenreChord(user, { libraryIds: [1] }); + + expect(repo.getSessionArchetypePoints).toHaveBeenCalledTimes(1); + expect(repo.getGenreReadingTime).toHaveBeenCalledTimes(1); + expect(repo.getReadingPacePoints).toHaveBeenCalledTimes(1); + expect(repo.getAuthorGenreChord).toHaveBeenCalledTimes(1); + }); }); diff --git a/server/src/modules/user/user-avatar-storage.service.test.ts b/server/src/modules/user/user-avatar-storage.service.test.ts new file mode 100644 index 000000000..bf1586a48 --- /dev/null +++ b/server/src/modules/user/user-avatar-storage.service.test.ts @@ -0,0 +1,67 @@ +vi.mock('fs/promises', () => ({ + access: vi.fn(), + mkdir: vi.fn(), + unlink: vi.fn(), + writeFile: vi.fn(), +})); + +import { Logger } from '@nestjs/common'; +import { access, mkdir, unlink, writeFile } from 'fs/promises'; + +import { UserAvatarStorageService } from './user-avatar-storage.service'; + +const accessMock = vi.mocked(access); +const mkdirMock = vi.mocked(mkdir); +const unlinkMock = vi.mocked(unlink); +const writeFileMock = vi.mocked(writeFile); + +describe('UserAvatarStorageService', () => { + const loggerWarn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + const config = { + get: vi.fn().mockReturnValue('/app-data'), + }; + + let service: UserAvatarStorageService; + + beforeEach(() => { + vi.clearAllMocks(); + config.get.mockReturnValue('/app-data'); + service = new UserAvatarStorageService(config as never); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('saves avatar bytes under the per-user directory', async () => { + const bytes = Buffer.from('avatar'); + + await service.saveAvatar(42, bytes); + + expect(mkdirMock).toHaveBeenCalledWith('/app-data/users/42', { recursive: true }); + expect(writeFileMock).toHaveBeenCalledWith('/app-data/users/42/avatar.jpg', bytes); + }); + + it('silently ignores missing avatar files on delete', async () => { + unlinkMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + + await expect(service.deleteAvatar(42)).resolves.toBeUndefined(); + expect(loggerWarn).not.toHaveBeenCalled(); + }); + + it('logs a warning when delete fails for reasons other than missing file', async () => { + unlinkMock.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' })); + + await expect(service.deleteAvatar(42)).resolves.toBeUndefined(); + expect(loggerWarn).toHaveBeenCalledWith('Failed to delete avatar for userId=42: permission denied'); + }); + + it('returns the avatar path only when file is readable', async () => { + accessMock.mockResolvedValue(undefined); + await expect(service.getAvatarPathIfExists(9)).resolves.toBe('/app-data/users/9/avatar.jpg'); + + accessMock.mockRejectedValue(new Error('not readable')); + await expect(service.getAvatarPathIfExists(9)).resolves.toBeNull(); + }); +}); diff --git a/server/src/modules/user/user.controller.test.ts b/server/src/modules/user/user.controller.test.ts index 4c65a2d60..3172e4fd5 100644 --- a/server/src/modules/user/user.controller.test.ts +++ b/server/src/modules/user/user.controller.test.ts @@ -1,12 +1,27 @@ -import { Permission } from '@projectx/types'; -import { BadRequestException } from '@nestjs/common'; +vi.mock('fs/promises', () => ({ + stat: vi.fn(), +})); +vi.mock('fs', () => ({ + createReadStream: vi.fn(), +})); + +import { AuditAction, Permission } from '@projectx/types'; +import { BadRequestException } from '@nestjs/common'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; + +import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import { UserController } from './user.controller'; import { MAX_USER_AVATAR_BYTES } from './user-avatar.service'; +const statMock = vi.mocked(stat); +const createReadStreamMock = vi.mocked(createReadStream); + describe('UserController', () => { const userService = { findAll: vi.fn(), + findAssignable: vi.fn(), updateMe: vi.fn(), findById: vi.fn(), createUser: vi.fn(), @@ -14,6 +29,8 @@ describe('UserController', () => { deleteUser: vi.fn(), setPermissions: vi.fn(), setSuperuser: vi.fn(), + getLibraryIds: vi.fn(), + setLibraries: vi.fn(), adminResetPassword: vi.fn(), }; const userAvatarService = { @@ -28,18 +45,14 @@ describe('UserController', () => { vi.resetAllMocks(); }); - it('passes optional pagination args to service', async () => { + it('delegates list and profile update operations', async () => { await controller.findAll(undefined, 25); + await controller.findAssignable(); + await controller.updateMe({ id: 7 } as any, { name: 'Updated' } as any); + expect(userService.findAll).toHaveBeenCalledWith(undefined, 25); - }); - - it('routes updateMe to current user id', async () => { - const user = { id: 7 } as any; - const dto = { name: 'Updated' }; - - await controller.updateMe(user, dto as any); - - expect(userService.updateMe).toHaveBeenCalledWith(7, dto); + expect(userService.findAssignable).toHaveBeenCalled(); + expect(userService.updateMe).toHaveBeenCalledWith(7, { name: 'Updated' }); }); it('uploads avatar bytes with multipart file limits', async () => { @@ -58,7 +71,20 @@ describe('UserController', () => { expect(userAvatarService.uploadOwnAvatar).toHaveBeenCalledWith(user, buffer, 'image/png'); }); - it('maps multipart file-size errors to a bad request response', async () => { + it('maps req.file size-limit errors to bad request', async () => { + const tooLargeError = Object.assign(new Error('request file too large'), { + code: 'FST_REQ_FILE_TOO_LARGE', + statusCode: 413, + }); + const req = { + file: vi.fn().mockRejectedValue(tooLargeError), + } as any; + + await expect(controller.uploadMyAvatar({ id: 7 } as any, req)).rejects.toThrow(BadRequestException); + await expect(controller.uploadMyAvatar({ id: 7 } as any, req)).rejects.toThrow('Image exceeds 5 MB limit'); + }); + + it('maps multipart toBuffer size-limit errors to bad request', async () => { const tooLargeError = Object.assign(new Error('request file too large'), { code: 'FST_REQ_FILE_TOO_LARGE', statusCode: 413, @@ -75,16 +101,104 @@ describe('UserController', () => { expect(userAvatarService.uploadOwnAvatar).not.toHaveBeenCalled(); }); - it('delegates permission and superuser management and admin reset', async () => { + it('rethrows unknown multipart errors and validates missing file', async () => { + const reqFileError = new Error('unexpected req.file failure'); + const req = { file: vi.fn().mockRejectedValue(reqFileError) } as any; + await expect(controller.uploadMyAvatar({ id: 7 } as any, req)).rejects.toBe(reqFileError); + + const reqWithoutFile = { file: vi.fn().mockResolvedValue(null) } as any; + await expect(controller.uploadMyAvatar({ id: 7 } as any, reqWithoutFile)).rejects.toThrow('No file provided'); + }); + + it('delegates avatar delete and returns avatar content with cache handling', async () => { + const reply = { + status: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + }; + + await controller.deleteMyAvatar({ id: 7 } as any); + expect(userAvatarService.removeOwnAvatar).toHaveBeenCalledWith({ id: 7 }); + + userAvatarService.getAvatarPath.mockResolvedValueOnce(null); + await controller.getAvatar({ id: 7 } as any, 2, reply as any); + expect(reply.status).toHaveBeenCalledWith(404); + expect(reply.send).toHaveBeenCalledWith({ message: 'No avatar for user 2' }); + + userAvatarService.getAvatarPath.mockResolvedValueOnce('/avatars/u2.jpg'); + statMock.mockResolvedValueOnce({ mtimeMs: 1234.56 } as any); + await controller.getAvatar({ id: 7 } as any, 2, reply as any, '"1234"'); + expect(reply.status).toHaveBeenCalledWith(304); + + const stream = {} as any; + createReadStreamMock.mockReturnValue(stream); + userAvatarService.getAvatarPath.mockResolvedValueOnce('/avatars/u2.jpg'); + statMock.mockResolvedValueOnce({ mtimeMs: 5678.9 } as any); + await controller.getAvatar({ id: 7 } as any, 2, reply as any); + + expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache'); + expect(reply.header).toHaveBeenCalledWith('ETag', '"5678"'); + expect(reply.type).toHaveBeenCalledWith('image/jpeg'); + expect(reply.send).toHaveBeenCalledWith(stream); + }); + + it('delegates user management operations', async () => { const requester = { id: 1 } as any; const dto = { permissionNames: [Permission.LibraryDownload] }; + const setLibrariesDto = { libraryIds: [1, 2] }; + await controller.findById(8); + await controller.createUser({ username: 'new' } as any); + await controller.updateUser(8, { name: 'updated' } as any, requester); + await controller.deleteUser(8, requester); await controller.setPermissions(8, dto as any, requester); await controller.setSuperuser(8, true, requester); + await controller.getLibraries(8); + await controller.setLibraries(8, setLibrariesDto as any, requester); await controller.adminResetPassword(8, requester); + expect(userService.findById).toHaveBeenCalledWith(8); + expect(userService.createUser).toHaveBeenCalledWith({ username: 'new' }); + expect(userService.updateUser).toHaveBeenCalledWith(8, { name: 'updated' }, requester); + expect(userService.deleteUser).toHaveBeenCalledWith(8, requester); expect(userService.setPermissions).toHaveBeenCalledWith(8, dto, requester); expect(userService.setSuperuser).toHaveBeenCalledWith(8, true, requester); + expect(userService.getLibraryIds).toHaveBeenCalledWith(8); + expect(userService.setLibraries).toHaveBeenCalledWith(8, [1, 2], requester); expect(userService.adminResetPassword).toHaveBeenCalledWith(8, requester); }); + + it('defines auditable metadata for create/update/delete/superuser operations', () => { + const createAudit = Reflect.getMetadata(AUDITABLE_KEY, UserController.prototype.createUser) as { + action: AuditAction; + getResourceId: (req: unknown, responseBody: { id?: number }) => number | undefined; + description: (req: unknown, responseBody: { username?: string }) => string; + }; + expect(createAudit.action).toBe(AuditAction.UserCreate); + expect(createAudit.getResourceId({} as never, { id: 9 })).toBe(9); + expect(createAudit.description({} as never, { username: 'alice' })).toBe("Created user 'alice'"); + + const updateAudit = Reflect.getMetadata(AUDITABLE_KEY, UserController.prototype.updateUser) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, responseBody: unknown) => string; + }; + expect(updateAudit.getResourceId({ params: { id: '15' } })).toBe(15); + expect(updateAudit.description({ params: { id: '15' } }, null)).toBe('Updated user #15'); + + const deleteAudit = Reflect.getMetadata(AUDITABLE_KEY, UserController.prototype.deleteUser) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record }, responseBody: unknown) => string; + }; + expect(deleteAudit.getResourceId({ params: { id: '16' } })).toBe(16); + expect(deleteAudit.description({ params: { id: '16' } }, null)).toBe('Deleted user #16'); + + const superuserAudit = Reflect.getMetadata(AUDITABLE_KEY, UserController.prototype.setSuperuser) as { + getResourceId: (req: { params: Record }) => number; + description: (req: { params: Record; body: { isSuperuser?: boolean } }, responseBody: unknown) => string; + }; + expect(superuserAudit.getResourceId({ params: { id: '17' } })).toBe(17); + expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: true } }, null)).toBe('Enabled superuser for user #17'); + expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: false } }, null)).toBe('Disabled superuser for user #17'); + }); }); diff --git a/server/src/modules/user/user.dto.test.ts b/server/src/modules/user/user.dto.test.ts index 3f589baac..47ec2bc9f 100644 --- a/server/src/modules/user/user.dto.test.ts +++ b/server/src/modules/user/user.dto.test.ts @@ -5,6 +5,7 @@ import { validate } from 'class-validator'; import { Permission } from '@projectx/types'; import { CreateUserDto } from './dto/create-user.dto'; +import { SetLibrariesDto } from './dto/set-libraries.dto'; import { SetPermissionsDto } from './dto/set-permissions.dto'; import { UpdateMeDto } from './dto/update-me.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -33,6 +34,12 @@ describe('User DTO validation', () => { expect(await hasErrors(plainToInstance(SetPermissionsDto, { permissionNames: [] }))).toBe(false); }); + it('SetLibrariesDto requires positive integer IDs', async () => { + expect(await hasErrors(plainToInstance(SetLibrariesDto, { libraryIds: [1, 2] }))).toBe(false); + expect(await hasErrors(plainToInstance(SetLibrariesDto, { libraryIds: [0] }))).toBe(true); + expect(await hasErrors(plainToInstance(SetLibrariesDto, { libraryIds: ['2'] }))).toBe(true); + }); + it('UpdateMeDto requires settings to be an object when provided', async () => { expect(await hasErrors(plainToInstance(UpdateMeDto, { settings: 'bad' }))).toBe(true); expect(await hasErrors(plainToInstance(UpdateMeDto, { settings: { theme: 'dark' } }))).toBe(false); diff --git a/server/src/modules/user/user.repository.test.ts b/server/src/modules/user/user.repository.test.ts index 9d76df20f..df2be7f89 100644 --- a/server/src/modules/user/user.repository.test.ts +++ b/server/src/modules/user/user.repository.test.ts @@ -89,6 +89,9 @@ describe('UserRepository', () => { const hashState = { update: vi.fn().mockReturnThis(), digest: vi.fn().mockReturnValue('token-hash') }; mockCreateHash.mockReturnValue(hashState as any); mockHash.mockResolvedValue('oidc-password-hash'); + + db.delete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); + db.query.users.findFirst.mockResolvedValue(null); }); it('findAll returns normalized count and skips user query when no ids are on the page', async () => { @@ -313,4 +316,148 @@ describe('UserRepository', () => { }), ); }); + + it('delete and setSuperuser issue scoped user updates', async () => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + db.delete.mockReturnValue({ where: deleteWhere }); + + await repo.delete(12); + expect(db.delete).toHaveBeenCalledWith(schema.users); + expect(deleteWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.users.id, right: 12 })); + + await repo.setSuperuser(12, true); + expect(db.update).toHaveBeenCalledWith(schema.users); + expect(updateSet).toHaveBeenCalledWith({ isSuperuser: true }); + expect(updateWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.users.id, right: 12 })); + }); + + it('setPermissions replaces permission rows in one transaction', async () => { + const txDeleteWhere = vi.fn().mockResolvedValue(undefined); + const txDelete = vi.fn().mockReturnValue({ where: txDeleteWhere }); + const txInsertValues = vi.fn().mockResolvedValue(undefined); + const txInsert = vi.fn().mockReturnValue({ values: txInsertValues }); + + db.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => + cb({ + delete: txDelete, + insert: txInsert, + }), + ); + + await repo.setPermissions(7, []); + expect(txDelete).toHaveBeenCalledWith(schema.userPermissions); + expect(txDeleteWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.userPermissions.userId, right: 7 })); + expect(txInsert).not.toHaveBeenCalled(); + + await repo.setPermissions(7, ['library_download' as never, 'kobo_sync' as never]); + expect(txInsertValues).toHaveBeenCalledWith([ + { userId: 7, permissionName: 'library_download' }, + { userId: 7, permissionName: 'kobo_sync' }, + ]); + }); + + it('incrementTokenVersion updates with SQL expression', async () => { + await repo.incrementTokenVersion(4); + + expect(db.update).toHaveBeenCalledWith(schema.users); + expect(updateSet).toHaveBeenCalledWith({ tokenVersion: expect.objectContaining({ op: 'sql' }) }); + expect(updateWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.users.id, right: 4 })); + }); + + it('findByEmail and findByOidcSubject delegate to query helpers', async () => { + await repo.findByEmail('alice@example.com'); + await repo.findByOidcSubject('sub', 'iss'); + + expect(db.query.users.findFirst).toHaveBeenCalledTimes(2); + expect(db.query.users.findFirst).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: expect.objectContaining({ op: 'eq', left: schema.users.email, right: 'alice@example.com' }), + }), + ); + expect(db.query.users.findFirst).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: expect.objectContaining({ + op: 'and', + clauses: [ + { op: 'eq', left: schema.users.oidcSubject, right: 'sub' }, + { op: 'eq', left: schema.users.oidcIssuer, right: 'iss' }, + ], + }), + }), + ); + }); + + it('findAvatarStateById selects avatar state columns only', async () => { + await repo.findAvatarStateById(9); + + expect(db.query.users.findFirst).toHaveBeenCalledWith({ + where: expect.objectContaining({ op: 'eq', left: schema.users.id, right: 9 }), + columns: { + id: true, + avatarUrl: true, + avatarSource: true, + avatarVersion: true, + }, + }); + }); + + it('setAvatarSourceAndBumpVersion updates avatar source and increments version', async () => { + await repo.setAvatarSourceAndBumpVersion(3, 'uploaded'); + + expect(db.update).toHaveBeenCalledWith(schema.users); + expect(updateSet).toHaveBeenCalledWith({ + avatarSource: 'uploaded', + avatarVersion: expect.objectContaining({ op: 'sql' }), + }); + expect(updateWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.users.id, right: 3 })); + }); + + it('assignViewerLibraries short-circuits empty lists and inserts viewer rows for non-empty input', async () => { + const onConflictDoNothing = vi.fn().mockResolvedValue(undefined); + const values = vi.fn().mockReturnValue({ onConflictDoNothing }); + db.insert.mockReturnValue({ values }); + + await repo.assignViewerLibraries(5, []); + expect(db.insert).not.toHaveBeenCalled(); + + await repo.assignViewerLibraries(5, [1, 2]); + expect(db.insert).toHaveBeenCalledWith(schema.userLibraryAccess); + expect(values).toHaveBeenCalledWith([ + { userId: 5, libraryId: 1, accessLevel: 'viewer' }, + { userId: 5, libraryId: 2, accessLevel: 'viewer' }, + ]); + expect(onConflictDoNothing).toHaveBeenCalled(); + }); + + it('findLibraryIdsByUserId and replaceViewerLibraries manage user-library rows', async () => { + const where = vi.fn().mockResolvedValue([{ libraryId: 10 }, { libraryId: 11 }]); + const from = vi.fn().mockReturnValue({ where }); + select.mockReturnValue({ from }); + + await expect(repo.findLibraryIdsByUserId(2)).resolves.toEqual([10, 11]); + + const txDeleteWhere = vi.fn().mockResolvedValue(undefined); + const txDelete = vi.fn().mockReturnValue({ where: txDeleteWhere }); + const txInsertValues = vi.fn().mockResolvedValue(undefined); + const txInsert = vi.fn().mockReturnValue({ values: txInsertValues }); + db.transaction.mockImplementation(async (cb: (tx: unknown) => Promise) => + cb({ + delete: txDelete, + insert: txInsert, + }), + ); + + await repo.replaceViewerLibraries(2, []); + expect(txDelete).toHaveBeenCalledWith(schema.userLibraryAccess); + expect(txDeleteWhere).toHaveBeenCalledWith(expect.objectContaining({ op: 'eq', left: schema.userLibraryAccess.userId, right: 2 })); + expect(txInsert).not.toHaveBeenCalled(); + + await repo.replaceViewerLibraries(2, [10, 11]); + expect(txInsertValues).toHaveBeenCalledWith([ + { userId: 2, libraryId: 10, accessLevel: 'viewer' }, + { userId: 2, libraryId: 11, accessLevel: 'viewer' }, + ]); + }); }); diff --git a/server/src/modules/user/user.service.test.ts b/server/src/modules/user/user.service.test.ts index b5707c455..3b641d48e 100644 --- a/server/src/modules/user/user.service.test.ts +++ b/server/src/modules/user/user.service.test.ts @@ -70,6 +70,34 @@ describe('UserService', () => { ); }); + it('delegates basic repository passthrough methods', async () => { + await service.findByUsername('alice'); + await service.findByEmail('alice@example.com'); + await service.findByOidcSubject('subject', 'issuer'); + await service.linkOidcIdentity(7, 'subject', 'issuer', 'https://img.example/a.jpg'); + await service.createOidcUser({ username: 'oidc', name: 'OIDC', email: 'oidc@example.com', oidcSubject: 'sub', oidcIssuer: 'iss' }); + await service.generatePasswordResetToken(7); + await service.incrementTokenVersion(7); + await service.findByIdWithPermissions(7); + await service.create({ username: 'new' } as any); + await service.findAll(1, 20); + await service.findAssignable(); + await service.setPermissionsDirectly(7, [Permission.LibraryDownload]); + + expect(userRepo.findByUsername).toHaveBeenCalledWith('alice'); + expect(userRepo.findByEmail).toHaveBeenCalledWith('alice@example.com'); + expect(userRepo.findByOidcSubject).toHaveBeenCalledWith('subject', 'issuer'); + expect(userRepo.linkOidcIdentity).toHaveBeenCalledWith(7, 'subject', 'issuer', 'https://img.example/a.jpg'); + expect(userRepo.createOidcUser).toHaveBeenCalled(); + expect(userRepo.generateResetToken).toHaveBeenCalledWith(7); + expect(userRepo.incrementTokenVersion).toHaveBeenCalledWith(7); + expect(userRepo.findByIdWithPermissions).toHaveBeenCalledWith(7); + expect(userRepo.create).toHaveBeenCalledWith({ username: 'new' }); + expect(userRepo.findAll).toHaveBeenCalledWith(1, 20); + expect(userRepo.findAssignable).toHaveBeenCalled(); + expect(userRepo.setPermissions).toHaveBeenCalledWith(7, [Permission.LibraryDownload]); + }); + it('createUser rejects duplicate emails', async () => { userRepo.findByUsername.mockResolvedValue(null); userRepo.findByEmail.mockResolvedValue({ id: 3 }); @@ -118,6 +146,30 @@ describe('UserService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('createUser skips permission/library writes when lists are empty', async () => { + userRepo.findByUsername.mockResolvedValue(null); + config.get.mockReturnValue(undefined); + + const result = await service.createUser({ + username: 'newuser', + name: 'New User', + email: 'new@example.com', + permissionNames: [], + libraryIds: [], + } as any); + + expect(userRepo.setPermissions).not.toHaveBeenCalled(); + expect(userRepo.assignViewerLibraries).not.toHaveBeenCalled(); + expect(result.resetUrl).toBe('http://localhost:5173/reset-password?token=reset-token'); + }); + + it('findById returns user and throws when missing', async () => { + userRepo.findByIdWithPermissions.mockResolvedValueOnce({ id: 4 }).mockResolvedValueOnce(null); + + await expect(service.findById(4)).resolves.toEqual({ id: 4 }); + await expect(service.findById(99)).rejects.toBeInstanceOf(NotFoundException); + }); + it('updateUser blocks self-deactivation', async () => { await expect(service.updateUser(1, { active: false }, reqUser({ id: 1 }))).rejects.toBeInstanceOf(ConflictException); }); @@ -128,6 +180,12 @@ describe('UserService', () => { await expect(service.updateUser(2, { name: 'x' }, reqUser())).rejects.toBeInstanceOf(ForbiddenException); }); + it('updateUser throws when target user does not exist', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue(null); + + await expect(service.updateUser(2, { name: 'x' }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException); + }); + it('updateUser prevents deactivating the last administrator', async () => { userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true }); userRepo.countOtherSuperusers.mockResolvedValue(0); @@ -142,12 +200,30 @@ describe('UserService', () => { await expect(service.updateUser(2, { email: 'dup@example.com' }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException); }); + it('updateUser throws if repository update returns null after checks', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false }); + userRepo.update.mockResolvedValue(null); + + await expect(service.updateUser(2, { name: 'x' }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException); + }); + it('updateMe rejects duplicate email conflicts explicitly', async () => { userRepo.findByEmail.mockResolvedValue({ id: 9 }); await expect(service.updateMe(2, { email: 'dup@example.com' })).rejects.toBeInstanceOf(ConflictException); }); + it('updateMe throws when repository update returns null', async () => { + userRepo.findByEmail.mockResolvedValue(null); + userRepo.update.mockResolvedValue(null); + + await expect(service.updateMe(2, { name: 'x' })).rejects.toBeInstanceOf(NotFoundException); + }); + + it('deleteUser blocks deleting your own account', async () => { + await expect(service.deleteUser(1, reqUser({ id: 1, isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException); + }); + it('deleteUser throws when target user does not exist', async () => { userRepo.findByIdWithPermissions.mockResolvedValue(null); userRepo.countOtherSuperusers.mockResolvedValue(0); @@ -155,6 +231,28 @@ describe('UserService', () => { await expect(service.deleteUser(88, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(NotFoundException); }); + it('deleteUser blocks non-superusers from deleting superuser accounts', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true }); + userRepo.countOtherSuperusers.mockResolvedValue(1); + + await expect(service.deleteUser(2, reqUser({ isSuperuser: false }))).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('deleteUser blocks deleting the last superuser', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true }); + userRepo.countOtherSuperusers.mockResolvedValue(0); + + await expect(service.deleteUser(2, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException); + }); + + it('deleteUser deletes non-superuser targets', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false }); + userRepo.countOtherSuperusers.mockResolvedValue(3); + + await expect(service.deleteUser(2, reqUser({ isSuperuser: false }))).resolves.toBeUndefined(); + expect(userRepo.delete).toHaveBeenCalledWith(2); + }); + it('setPermissions blocks modifying own permissions', async () => { await expect(service.setPermissions(1, { permissionNames: [] }, reqUser({ id: 1 }))).rejects.toBeInstanceOf(ConflictException); }); @@ -165,6 +263,14 @@ describe('UserService', () => { await expect(service.setPermissions(2, { permissionNames: [Permission.LibraryDownload] }, reqUser())).rejects.toBeInstanceOf(ForbiddenException); }); + it('setPermissions throws when target user does not exist', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue(null); + + await expect(service.setPermissions(2, { permissionNames: [Permission.LibraryDownload] }, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + it('setPermissions deduplicates permission names', async () => { userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false }); @@ -198,6 +304,13 @@ describe('UserService', () => { await expect(service.setSuperuser(2, false, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException); }); + it('setSuperuser writes the target superuser flag when allowed', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false }); + + await expect(service.setSuperuser(2, true, reqUser({ isSuperuser: true }))).resolves.toBeUndefined(); + expect(userRepo.setSuperuser).toHaveBeenCalledWith(2, true); + }); + it('setSuperuser skips last-admin check when target is already non-superuser', async () => { userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false }); @@ -227,6 +340,12 @@ describe('UserService', () => { await expect(service.setLibraries(2, [3, 7], reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException); }); + it('setLibraries blocks non-superuser updates to superuser targets', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: true }); + + await expect(service.setLibraries(2, [3], reqUser({ isSuperuser: false }))).rejects.toBeInstanceOf(ForbiddenException); + }); + it('setLibraries deduplicates IDs before replacing access rows', async () => { userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false }); userRepo.findExistingLibraryIds.mockResolvedValue([3, 7]); @@ -253,4 +372,14 @@ describe('UserService', () => { await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException); }); + + it('adminResetPassword returns default app URL when config is missing', async () => { + userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false, provisioningMethod: 'local' }); + userRepo.generateResetToken.mockResolvedValue('token-2'); + config.get.mockReturnValue(undefined); + + await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).resolves.toEqual({ + resetUrl: 'http://localhost:5173/reset-password?token=token-2', + }); + }); }); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index a68a66460..2746b7257 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -19,12 +19,24 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'lcov'], include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/scripts/**', 'src/**/*.module.ts', 'src/main.ts'], + exclude: [ + 'src/**/*.test.ts', + 'src/scripts/**', + 'src/**/*.module.ts', + 'src/main.ts', + 'src/db/schema/**', + 'src/**/*.types.ts', + 'src/**/*.interface.ts', + 'src/**/*.constants.ts', + 'src/**/*.enum.ts', + 'src/config/**', + 'src/common/types/**', + ], thresholds: { - statements: 50, - branches: 43, - functions: 46, - lines: 52, + statements: 90, + branches: 75, + functions: 90, + lines: 90, }, }, },