From 0a04d760c568d43681aa68d0e1055b44820121ca Mon Sep 17 00:00:00 2001 From: Sascha Date: Fri, 10 Jul 2026 14:01:08 +0200 Subject: [PATCH 1/4] feat: split tests by layer --- .../execute-tests-and-startup-checks.yml | 13 ++- sake/package.json | 6 +- sake/scripts/run-koreader-interop.sh | 4 +- .../infrastructure/storage/S3Storage.ts | 8 +- .../clients}/hardcoverClient.test.ts | 0 .../hardcoverMetadataProvider.test.ts | 0 .../isbndbMetadataProvider.test.ts | 0 .../openLibraryMetadataProvider.test.ts | 0 .../annaArchiveSearchProvider.test.ts | 0 .../integration/api/opds/opdsRoutes.test.ts | 46 ++++++++++ .../api}/queue/queueRoutes.test.ts | 4 +- .../api/webdav/webdavRoutes.test.ts | 54 ++++++++++++ .../integration/db/bookRepository.test.ts | 87 +++++++++++++++++++ .../integration/storage/s3Storage.test.ts | 57 ++++++++++++ .../{ => interop}/reader/commonrequire.lua | 0 .../reader/koreader-xpointer-interop-spec.lua | 0 .../client/base}/authResponseSignals.test.ts | 0 .../routes}/queueSearchBookToLibrary.test.ts | 0 .../shelfRules/shelfRulesView.test.ts | 0 .../library/libraryRouteHelpers.test.ts | 0 .../lib/features}/library/libraryView.test.ts | 0 .../lib/features}/queue/queueView.test.ts | 0 .../features}/reader/koreaderSidecar.test.ts | 0 .../features}/reader/koreaderXPointer.test.ts | 0 .../features}/reader/readerAppearance.test.ts | 2 +- .../reader/readerFooterStatus.test.ts | 0 .../features}/reader/readerPagination.test.ts | 0 .../reader/readerPreferences.test.ts | 0 .../features}/reader/readerSaveQueue.test.ts | 0 .../features}/reader/readerSessionId.test.ts | 0 .../reader/readerTapNavigation.test.ts | 0 .../reader/readerTapNavigationBinding.test.ts | 0 .../features}/reader/readerWakeLock.test.ts | 0 .../lib/features}/search/searchView.test.ts | 0 .../lib/features}/stats/statsView.test.ts | 0 .../services}/epubMetadataService.test.ts | 0 .../externalBookMetadataService.test.ts | 0 .../hardcoverProgressSyncService.test.ts | 0 .../services}/koreaderPluginVersion.test.ts | 0 .../services}/managedBookCoverService.test.ts | 0 .../metadataDescriptionSanitizer.test.ts | 0 .../use-cases/ListDavDirectoryUseCase.test.ts | 30 +++++++ .../applyMetadataCandidateUseCase.test.ts | 0 .../use-cases}/authCleanup.test.ts | 0 .../use-cases}/deviceLogUseCases.test.ts | 0 .../use-cases}/downloadBookUseCase.test.ts | 0 .../exportDeviceLibraryBookUseCase.test.ts | 0 .../use-cases}/getAppVersionUseCase.test.ts | 0 .../getLibraryBookContentUseCase.test.ts | 0 .../use-cases}/getQueueStatusUseCase.test.ts | 0 .../importLibraryBookCoverUseCase.test.ts | 0 .../koreaderPluginDownloadUseCase.test.ts | 0 .../use-cases}/libraryCoverLifecycle.test.ts | 0 .../libraryPublicationDateMetadata.test.ts | 0 .../use-cases}/putLibraryFileUseCase.test.ts | 0 .../use-cases}/putProgressUseCase.test.ts | 0 .../use-cases}/queueSearchBookUseCase.test.ts | 0 .../searchMetadataCandidatesUseCase.test.ts | 0 .../uploadLibraryBookCoverUseCase.test.ts | 0 .../lib/server}/auth/cookies.test.ts | 0 .../lib/server}/auth/deviceBinding.test.ts | 0 .../lib/server}/auth/requestAccess.test.ts | 0 .../server}/config/activatedProviders.test.ts | 0 .../config/infrastructureConfig.test.ts | 0 .../server/http}/searchBooksRequest.test.ts | 0 .../http/zlibraryDownloadRequest.test.ts | 0 .../lib/server/http}/zlibraryRequests.test.ts | 0 .../logging}/deviceLogFeed.test.ts | 0 .../logging}/webappLogFeed.test.ts | 0 .../queue/queuePersistence.test.ts | 0 .../bookRepositoryHelpers.test.ts | 0 .../storage/S3ListPagination.test.ts} | 0 .../server/utils}/createLazySingleton.test.ts | 0 .../lib/types/Logs}/logsView.test.ts | 0 .../lib}/webappVersion.test.ts | 0 .../unit/routes/api/opds/feedBuilder.test.ts | 73 ++++++++++++++++ 76 files changed, 375 insertions(+), 9 deletions(-) rename sake/tests/{integrations => contract/server/infrastructure/clients}/hardcoverClient.test.ts (100%) rename sake/tests/{metadata => contract/server/infrastructure/metadata-providers}/hardcoverMetadataProvider.test.ts (100%) rename sake/tests/{metadata => contract/server/infrastructure/metadata-providers}/isbndbMetadataProvider.test.ts (100%) rename sake/tests/{metadata => contract/server/infrastructure/metadata-providers}/openLibraryMetadataProvider.test.ts (100%) rename sake/tests/{search => contract/server/infrastructure/search-providers}/annaArchiveSearchProvider.test.ts (100%) create mode 100644 sake/tests/integration/api/opds/opdsRoutes.test.ts rename sake/tests/{ => integration/api}/queue/queueRoutes.test.ts (83%) create mode 100644 sake/tests/integration/api/webdav/webdavRoutes.test.ts create mode 100644 sake/tests/integration/db/bookRepository.test.ts create mode 100644 sake/tests/integration/storage/s3Storage.test.ts rename sake/tests/{ => interop}/reader/commonrequire.lua (100%) rename sake/tests/{ => interop}/reader/koreader-xpointer-interop-spec.lua (100%) rename sake/tests/{auth => unit/lib/client/base}/authResponseSignals.test.ts (100%) rename sake/tests/{search => unit/lib/client/routes}/queueSearchBookToLibrary.test.ts (100%) rename sake/tests/{ => unit/lib/components}/shelfRules/shelfRulesView.test.ts (100%) rename sake/tests/{ => unit/lib/features}/library/libraryRouteHelpers.test.ts (100%) rename sake/tests/{ => unit/lib/features}/library/libraryView.test.ts (100%) rename sake/tests/{ => unit/lib/features}/queue/queueView.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/koreaderSidecar.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/koreaderXPointer.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerAppearance.test.ts (97%) rename sake/tests/{ => unit/lib/features}/reader/readerFooterStatus.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerPagination.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerPreferences.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerSaveQueue.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerSessionId.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerTapNavigation.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerTapNavigationBinding.test.ts (100%) rename sake/tests/{ => unit/lib/features}/reader/readerWakeLock.test.ts (100%) rename sake/tests/{ => unit/lib/features}/search/searchView.test.ts (100%) rename sake/tests/{ => unit/lib/features}/stats/statsView.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/services}/epubMetadataService.test.ts (100%) rename sake/tests/{metadata => unit/lib/server/application/services}/externalBookMetadataService.test.ts (100%) rename sake/tests/{integrations => unit/lib/server/application/services}/hardcoverProgressSyncService.test.ts (100%) rename sake/tests/{plugin => unit/lib/server/application/services}/koreaderPluginVersion.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/services}/managedBookCoverService.test.ts (100%) rename sake/tests/{metadata => unit/lib/server/application/services}/metadataDescriptionSanitizer.test.ts (100%) create mode 100644 sake/tests/unit/lib/server/application/use-cases/ListDavDirectoryUseCase.test.ts rename sake/tests/{metadata => unit/lib/server/application/use-cases}/applyMetadataCandidateUseCase.test.ts (100%) rename sake/tests/{auth => unit/lib/server/application/use-cases}/authCleanup.test.ts (100%) rename sake/tests/{logs => unit/lib/server/application/use-cases}/deviceLogUseCases.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/downloadBookUseCase.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/exportDeviceLibraryBookUseCase.test.ts (100%) rename sake/tests/{version => unit/lib/server/application/use-cases}/getAppVersionUseCase.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/getLibraryBookContentUseCase.test.ts (100%) rename sake/tests/{queue => unit/lib/server/application/use-cases}/getQueueStatusUseCase.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/importLibraryBookCoverUseCase.test.ts (100%) rename sake/tests/{plugin => unit/lib/server/application/use-cases}/koreaderPluginDownloadUseCase.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/libraryCoverLifecycle.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/libraryPublicationDateMetadata.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/putLibraryFileUseCase.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/putProgressUseCase.test.ts (100%) rename sake/tests/{search => unit/lib/server/application/use-cases}/queueSearchBookUseCase.test.ts (100%) rename sake/tests/{metadata => unit/lib/server/application/use-cases}/searchMetadataCandidatesUseCase.test.ts (100%) rename sake/tests/{library => unit/lib/server/application/use-cases}/uploadLibraryBookCoverUseCase.test.ts (100%) rename sake/tests/{ => unit/lib/server}/auth/cookies.test.ts (100%) rename sake/tests/{ => unit/lib/server}/auth/deviceBinding.test.ts (100%) rename sake/tests/{ => unit/lib/server}/auth/requestAccess.test.ts (100%) rename sake/tests/{ => unit/lib/server}/config/activatedProviders.test.ts (100%) rename sake/tests/{ => unit/lib/server}/config/infrastructureConfig.test.ts (100%) rename sake/tests/{search => unit/lib/server/http}/searchBooksRequest.test.ts (100%) rename sake/tests/{ => unit/lib}/server/http/zlibraryDownloadRequest.test.ts (100%) rename sake/tests/{search => unit/lib/server/http}/zlibraryRequests.test.ts (100%) rename sake/tests/{logs => unit/lib/server/infrastructure/logging}/deviceLogFeed.test.ts (100%) rename sake/tests/{logs => unit/lib/server/infrastructure/logging}/webappLogFeed.test.ts (100%) rename sake/tests/{ => unit/lib/server/infrastructure}/queue/queuePersistence.test.ts (100%) rename sake/tests/{library => unit/lib/server/infrastructure/repositories}/bookRepositoryHelpers.test.ts (100%) rename sake/tests/{infrastructure/s3Storage.test.ts => unit/lib/server/infrastructure/storage/S3ListPagination.test.ts} (100%) rename sake/tests/{server => unit/lib/server/utils}/createLazySingleton.test.ts (100%) rename sake/tests/{logs => unit/lib/types/Logs}/logsView.test.ts (100%) rename sake/tests/{version => unit/lib}/webappVersion.test.ts (100%) create mode 100644 sake/tests/unit/routes/api/opds/feedBuilder.test.ts diff --git a/.github/workflows/execute-tests-and-startup-checks.yml b/.github/workflows/execute-tests-and-startup-checks.yml index 89fe921..be38a3f 100644 --- a/.github/workflows/execute-tests-and-startup-checks.yml +++ b/.github/workflows/execute-tests-and-startup-checks.yml @@ -28,8 +28,17 @@ jobs: - name: Check types run: bun run check - - name: Run tests - run: bun test + - name: Run unit tests + run: bun run test:unit + + - name: Run contract tests + run: bun run test:contract + + - name: Run integration tests + run: bun run test:integration + + - name: Run complete test suite + run: bun run test:all koreader-reader-interop: runs-on: ubuntu-latest diff --git a/sake/package.json b/sake/package.json index ba6ffe5..f19f145 100644 --- a/sake/package.json +++ b/sake/package.json @@ -13,7 +13,11 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "test": "bun test", + "test": "bun run test:all", + "test:unit": "bun test tests/unit", + "test:contract": "bun test tests/contract", + "test:integration": "bun test tests/integration", + "test:all": "bun test tests/unit tests/contract tests/integration", "test:reader:interop": "bash ./scripts/run-koreader-interop.sh", "db:generate": "drizzle-kit generate && node ./scripts/db/normalize-journal-timestamps.mjs", "db:migrate": "node ./scripts/db/run-with-project-env.mjs ./node_modules/drizzle-kit/bin.cjs migrate", diff --git a/sake/scripts/run-koreader-interop.sh b/sake/scripts/run-koreader-interop.sh index 999acce..4cd1dfc 100755 --- a/sake/scripts/run-koreader-interop.sh +++ b/sake/scripts/run-koreader-interop.sh @@ -55,11 +55,11 @@ bun run "$ROOT_DIR/scripts/create-reader-interop-sidecar.ts" "$TEMP_DIR/metadata KO_DIR="$TEMP_DIR/koreader/lib/koreader" export KO_HOME="$TEMP_DIR/ko-home" mkdir -p "$KO_HOME" -export LUA_PATH="$ROOT_DIR/tests/reader/?.lua;common/?.lua;frontend/?.lua;;" +export LUA_PATH="$ROOT_DIR/tests/interop/reader/?.lua;common/?.lua;frontend/?.lua;;" export LUA_CPATH='common/?.so;;' export SAKE_READER_TEST_EPUB="$TEMP_DIR/interop.epub" export SAKE_READER_TEST_SIDECAR="$TEMP_DIR/metadata.epub.lua" cd "$KO_DIR" exec ./luajit "$ROOT_DIR/scripts/koreader-interop-runner.lua" \ - "$ROOT_DIR/tests/reader/koreader-xpointer-interop-spec.lua" + "$ROOT_DIR/tests/interop/reader/koreader-xpointer-interop-spec.lua" diff --git a/sake/src/lib/server/infrastructure/storage/S3Storage.ts b/sake/src/lib/server/infrastructure/storage/S3Storage.ts index 29b0181..d613270 100644 --- a/sake/src/lib/server/infrastructure/storage/S3Storage.ts +++ b/sake/src/lib/server/infrastructure/storage/S3Storage.ts @@ -15,7 +15,13 @@ export class S3Storage implements StoragePort { private readonly s3: S3Client; private readonly bucket: string; - constructor() { + constructor(s3Client?: S3Client, bucket?: string) { + if (s3Client) { + this.s3 = s3Client; + this.bucket = bucket ?? ''; + return; + } + const config = getS3Config(); this.bucket = config.bucket; diff --git a/sake/tests/integrations/hardcoverClient.test.ts b/sake/tests/contract/server/infrastructure/clients/hardcoverClient.test.ts similarity index 100% rename from sake/tests/integrations/hardcoverClient.test.ts rename to sake/tests/contract/server/infrastructure/clients/hardcoverClient.test.ts diff --git a/sake/tests/metadata/hardcoverMetadataProvider.test.ts b/sake/tests/contract/server/infrastructure/metadata-providers/hardcoverMetadataProvider.test.ts similarity index 100% rename from sake/tests/metadata/hardcoverMetadataProvider.test.ts rename to sake/tests/contract/server/infrastructure/metadata-providers/hardcoverMetadataProvider.test.ts diff --git a/sake/tests/metadata/isbndbMetadataProvider.test.ts b/sake/tests/contract/server/infrastructure/metadata-providers/isbndbMetadataProvider.test.ts similarity index 100% rename from sake/tests/metadata/isbndbMetadataProvider.test.ts rename to sake/tests/contract/server/infrastructure/metadata-providers/isbndbMetadataProvider.test.ts diff --git a/sake/tests/metadata/openLibraryMetadataProvider.test.ts b/sake/tests/contract/server/infrastructure/metadata-providers/openLibraryMetadataProvider.test.ts similarity index 100% rename from sake/tests/metadata/openLibraryMetadataProvider.test.ts rename to sake/tests/contract/server/infrastructure/metadata-providers/openLibraryMetadataProvider.test.ts diff --git a/sake/tests/search/annaArchiveSearchProvider.test.ts b/sake/tests/contract/server/infrastructure/search-providers/annaArchiveSearchProvider.test.ts similarity index 100% rename from sake/tests/search/annaArchiveSearchProvider.test.ts rename to sake/tests/contract/server/infrastructure/search-providers/annaArchiveSearchProvider.test.ts diff --git a/sake/tests/integration/api/opds/opdsRoutes.test.ts b/sake/tests/integration/api/opds/opdsRoutes.test.ts new file mode 100644 index 0000000..8e17c64 --- /dev/null +++ b/sake/tests/integration/api/opds/opdsRoutes.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +// @ts-expect-error Bun's test-only mock API is available at runtime but excluded from the app tsconfig. +import { mock } from 'bun:test'; +import { hashPassword } from '$lib/server/application/services/LocalAuthService'; + +const passwordHash = await hashPassword('opds-password'); + +mock.module('$lib/server/application/composition', () => ({ + userRepository: { + getByUsername: async (username: string) => + username === 'reader' + ? { id: 1, username, passwordHash, basicAuthPasswordHash: null, isDisabled: false } + : undefined + }, + getQueueStatusUseCase: { execute: async () => ({ ok: true, value: {} }) }, + queueDownloadUseCase: { execute: async () => ({ ok: true, value: {} }) }, + listDavDirectoryUseCase: { + execute: async ({ path }: { path: string }) => ({ ok: true, value: { xml: `${path}` } }) + } +})); + +const { GET } = await import('../../../../src/routes/api/opds/+server'); + +function event(request: Request) { + return { request, url: new URL(request.url), locals: { logger: undefined } } as never; +} + +describe('OPDS HTTP boundary', () => { + test('requires Basic authentication', async () => { + const response = await GET(event(new Request('http://localhost/api/opds'))); + assert.equal(response.status, 401); + assert.equal(response.headers.get('www-authenticate'), 'Basic realm="OPDS Catalog"'); + }); + + test('returns the authenticated navigation catalog', async () => { + const authorization = `Basic ${Buffer.from('reader:opds-password').toString('base64')}`; + const response = await GET( + event(new Request('http://localhost/api/opds', { headers: { authorization } })) + ); + + assert.equal(response.status, 200); + assert.match(await response.text(), /Sake OPDS Catalog/); + assert.equal(response.headers.get('content-type'), 'application/atom+xml;charset=utf-8'); + }); +}); diff --git a/sake/tests/queue/queueRoutes.test.ts b/sake/tests/integration/api/queue/queueRoutes.test.ts similarity index 83% rename from sake/tests/queue/queueRoutes.test.ts rename to sake/tests/integration/api/queue/queueRoutes.test.ts index 6ce80bf..ef95720 100644 --- a/sake/tests/queue/queueRoutes.test.ts +++ b/sake/tests/integration/api/queue/queueRoutes.test.ts @@ -5,8 +5,8 @@ import { mock } from 'bun:test'; mock.module('$env/dynamic/private', () => ({ env: process.env })); -const { GET: getQueue } = await import('../../src/routes/api/queue/+server'); -const { GET: getLegacyQueue } = await import('../../src/routes/api/zlibrary/queue/+server'); +const { GET: getQueue } = await import('../../../../src/routes/api/queue/+server'); +const { GET: getLegacyQueue } = await import('../../../../src/routes/api/zlibrary/queue/+server'); const unauthenticatedEvent = { locals: { diff --git a/sake/tests/integration/api/webdav/webdavRoutes.test.ts b/sake/tests/integration/api/webdav/webdavRoutes.test.ts new file mode 100644 index 0000000..b8ea8cf --- /dev/null +++ b/sake/tests/integration/api/webdav/webdavRoutes.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +// @ts-expect-error Bun's test-only mock API is available at runtime but excluded from the app tsconfig. +import { mock } from 'bun:test'; +import { hashPassword } from '$lib/server/application/services/LocalAuthService'; + +const passwordHash = await hashPassword('dav-password'); + +mock.module('$lib/server/application/composition', () => ({ + userRepository: { + getByUsername: async (username: string) => + username === 'reader' + ? { id: 1, username, passwordHash, basicAuthPasswordHash: null, isDisabled: false } + : undefined + }, + listDavDirectoryUseCase: { + execute: async ({ path }: { path: string }) => ({ ok: true, value: { xml: `${path}` } }) + }, + getQueueStatusUseCase: { execute: async () => ({ ok: true, value: {} }) }, + queueDownloadUseCase: { execute: async () => ({ ok: true, value: {} }) } +})); + +const { fallback } = await import('../../../../src/routes/api/dav/[...path]/+server'); + +function event(request: Request) { + return { request, url: new URL(request.url), locals: { logger: undefined } } as never; +} + +describe('WebDAV HTTP boundary', () => { + test('requires Basic authentication for PROPFIND', async () => { + const response = await fallback( + event(new Request('http://localhost/api/dav/library', { method: 'PROPFIND' })) + ); + assert.equal(response.status, 401); + assert.equal(response.headers.get('www-authenticate'), 'Basic realm="WebDAV"'); + }); + + test('rejects methods other than PROPFIND', async () => { + const response = await fallback(event(new Request('http://localhost/api/dav/library'))); + assert.equal(response.status, 405); + assert.deepEqual(await response.json(), { error: 'Method not allowed' }); + }); + + test('returns a multi-status response for an authenticated directory request', async () => { + const authorization = `Basic ${Buffer.from('reader:dav-password').toString('base64')}`; + const response = await fallback( + event(new Request('http://localhost/api/dav/library', { method: 'PROPFIND', headers: { authorization } })) + ); + + assert.equal(response.status, 207); + assert.equal(response.headers.get('content-type'), 'application/xml; charset=utf-8'); + assert.equal(await response.text(), 'library'); + }); +}); diff --git a/sake/tests/integration/db/bookRepository.test.ts b/sake/tests/integration/db/bookRepository.test.ts new file mode 100644 index 0000000..bc493d0 --- /dev/null +++ b/sake/tests/integration/db/bookRepository.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, test } from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { migrate } from 'drizzle-orm/libsql/migrator'; +import type { CreateBookInput } from '$lib/server/domain/entities/Book'; +// @ts-expect-error Bun's test-only mock API is available at runtime but excluded from the app tsconfig. +import { mock } from 'bun:test'; + +const databasePath = join(tmpdir(), `sake-repository-${process.pid}-${randomUUID()}.db`); +process.env.LIBSQL_URL = `file:${databasePath}`; +process.env.LIBSQL_AUTH_TOKEN = ''; + +mock.module('$env/dynamic/private', () => ({ env: process.env })); +const { BookRepository } = await import('$lib/server/infrastructure/repositories/BookRepository'); + +const book: CreateBookInput = { + zLibId: null, + s3_storage_key: 'library/integration.epub', + title: 'Integration Book', + author: 'Test Author', + publisher: null, + series: null, + volume: null, + series_index: null, + edition: null, + identifier: 'integration-1', + pages: 100, + description: null, + google_books_id: null, + open_library_key: null, + hardcover_id: null, + amazon_asin: null, + external_rating: null, + external_rating_count: null, + cover: null, + extension: 'epub', + filesize: 123, + language: 'en', + year: 2026, + month: 7, + day: 10 +}; + +describe('BookRepository with a migrated libSQL database', () => { + let repository: InstanceType; + + before(async () => { + const client = createClient({ url: `file:${databasePath}` }); + await migrate(drizzle(client), { migrationsFolder: new URL('../../../drizzle', import.meta.url).pathname }); + repository = new BookRepository(); + }); + + after(async () => { + await rm(databasePath, { force: true }); + }); + + test('persists, reads, updates, and deletes a book through the concrete repository', async () => { + const created = await repository.create(book); + assert.equal(created.title, book.title); + assert.equal((await repository.getById(created.id))?.s3_storage_key, book.s3_storage_key); + + await repository.updateRating(created.id, 5); + await repository.updateProgress(created.id, 'progress/integration.lua', 1, '2026-07-10T00:00:00.000Z'); + const updated = await repository.getById(created.id); + assert.equal(updated?.rating, 5); + assert.equal(updated?.progress_percent, 1); + assert.equal(updated?.read_at, '2026-07-10T00:00:00.000Z'); + + await repository.delete(created.id); + assert.equal(await repository.getByIdIncludingTrashed(created.id), undefined); + }); + + test('keeps trashed rows available to explicit queries but out of active listings', async () => { + const created = await repository.create({ ...book, s3_storage_key: 'library/trashed.epub' }); + await repository.moveToTrash(created.id, '2026-07-10T00:00:00.000Z', '2026-08-10T00:00:00.000Z'); + + assert.equal(await repository.getById(created.id), undefined); + assert.equal((await repository.getByIdIncludingTrashed(created.id))?.deleted_at, '2026-07-10T00:00:00.000Z'); + assert.equal((await repository.getAll()).some((entry) => entry.id === created.id), false); + await repository.delete(created.id); + }); +}); diff --git a/sake/tests/integration/storage/s3Storage.test.ts b/sake/tests/integration/storage/s3Storage.test.ts new file mode 100644 index 0000000..9dedd14 --- /dev/null +++ b/sake/tests/integration/storage/s3Storage.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { describe, test } from 'node:test'; +import { S3ServiceException, type S3Client } from '@aws-sdk/client-s3'; +// @ts-expect-error Bun's test-only mock API is available at runtime but excluded from the app tsconfig. +import { mock } from 'bun:test'; + +mock.module('$env/dynamic/private', () => ({ env: process.env })); +const { S3Storage } = await import('$lib/server/infrastructure/storage/S3Storage'); + +type CommandInput = { input: Record; constructor: { name: string } }; + +describe('S3Storage boundary', () => { + test('maps object operations and paginated listing through the S3 command contract', async () => { + const objects = new Map(); + const fakeClient = { + send: async (command: unknown) => { + const { input, constructor } = command as CommandInput; + const key = String(input.Key ?? ''); + + switch (constructor.name) { + case 'PutObjectCommand': + objects.set(key, Buffer.from(input.Body as Uint8Array)); + return {}; + case 'GetObjectCommand': + return { Body: Readable.from([objects.get(key) ?? Buffer.from('')]) }; + case 'HeadObjectCommand': + if (objects.has(key)) return {}; + throw new S3ServiceException({ + name: 'NotFound', + message: 'not found', + $fault: 'client', + $metadata: { httpStatusCode: 404 } + }); + case 'DeleteObjectCommand': + objects.delete(key); + return {}; + case 'ListObjectsV2Command': + return { + Contents: [...objects.entries()].map(([objectKey, body]) => ({ Key: objectKey, Size: body.length })) + }; + default: + throw new Error(`Unexpected S3 command: ${constructor.name}`); + } + } + } as unknown as S3Client; + + const storage = new S3Storage(fakeClient, 'test-bucket'); + await storage.put('library/book.epub', Buffer.from('book'), 'application/epub+zip'); + assert.deepEqual(await storage.get('library/book.epub'), Buffer.from('book')); + assert.equal(await storage.exists('library/book.epub'), true); + assert.deepEqual(await storage.list('library/'), [{ key: 'library/book.epub', size: 4, lastModified: undefined }]); + + await storage.delete('library/book.epub'); + assert.equal(await storage.exists('library/book.epub'), false); + }); +}); diff --git a/sake/tests/reader/commonrequire.lua b/sake/tests/interop/reader/commonrequire.lua similarity index 100% rename from sake/tests/reader/commonrequire.lua rename to sake/tests/interop/reader/commonrequire.lua diff --git a/sake/tests/reader/koreader-xpointer-interop-spec.lua b/sake/tests/interop/reader/koreader-xpointer-interop-spec.lua similarity index 100% rename from sake/tests/reader/koreader-xpointer-interop-spec.lua rename to sake/tests/interop/reader/koreader-xpointer-interop-spec.lua diff --git a/sake/tests/auth/authResponseSignals.test.ts b/sake/tests/unit/lib/client/base/authResponseSignals.test.ts similarity index 100% rename from sake/tests/auth/authResponseSignals.test.ts rename to sake/tests/unit/lib/client/base/authResponseSignals.test.ts diff --git a/sake/tests/search/queueSearchBookToLibrary.test.ts b/sake/tests/unit/lib/client/routes/queueSearchBookToLibrary.test.ts similarity index 100% rename from sake/tests/search/queueSearchBookToLibrary.test.ts rename to sake/tests/unit/lib/client/routes/queueSearchBookToLibrary.test.ts diff --git a/sake/tests/shelfRules/shelfRulesView.test.ts b/sake/tests/unit/lib/components/shelfRules/shelfRulesView.test.ts similarity index 100% rename from sake/tests/shelfRules/shelfRulesView.test.ts rename to sake/tests/unit/lib/components/shelfRules/shelfRulesView.test.ts diff --git a/sake/tests/library/libraryRouteHelpers.test.ts b/sake/tests/unit/lib/features/library/libraryRouteHelpers.test.ts similarity index 100% rename from sake/tests/library/libraryRouteHelpers.test.ts rename to sake/tests/unit/lib/features/library/libraryRouteHelpers.test.ts diff --git a/sake/tests/library/libraryView.test.ts b/sake/tests/unit/lib/features/library/libraryView.test.ts similarity index 100% rename from sake/tests/library/libraryView.test.ts rename to sake/tests/unit/lib/features/library/libraryView.test.ts diff --git a/sake/tests/queue/queueView.test.ts b/sake/tests/unit/lib/features/queue/queueView.test.ts similarity index 100% rename from sake/tests/queue/queueView.test.ts rename to sake/tests/unit/lib/features/queue/queueView.test.ts diff --git a/sake/tests/reader/koreaderSidecar.test.ts b/sake/tests/unit/lib/features/reader/koreaderSidecar.test.ts similarity index 100% rename from sake/tests/reader/koreaderSidecar.test.ts rename to sake/tests/unit/lib/features/reader/koreaderSidecar.test.ts diff --git a/sake/tests/reader/koreaderXPointer.test.ts b/sake/tests/unit/lib/features/reader/koreaderXPointer.test.ts similarity index 100% rename from sake/tests/reader/koreaderXPointer.test.ts rename to sake/tests/unit/lib/features/reader/koreaderXPointer.test.ts diff --git a/sake/tests/reader/readerAppearance.test.ts b/sake/tests/unit/lib/features/reader/readerAppearance.test.ts similarity index 97% rename from sake/tests/reader/readerAppearance.test.ts rename to sake/tests/unit/lib/features/reader/readerAppearance.test.ts index 22840c7..6a00d8d 100644 --- a/sake/tests/reader/readerAppearance.test.ts +++ b/sake/tests/unit/lib/features/reader/readerAppearance.test.ts @@ -5,7 +5,7 @@ import { parseReaderTheme, registerReaderAppearance, type ReaderTheme -} from '../../src/lib/features/reader/readerAppearance'; +} from '$lib/features/reader/readerAppearance'; function createThemeController() { const registered: object[] = []; diff --git a/sake/tests/reader/readerFooterStatus.test.ts b/sake/tests/unit/lib/features/reader/readerFooterStatus.test.ts similarity index 100% rename from sake/tests/reader/readerFooterStatus.test.ts rename to sake/tests/unit/lib/features/reader/readerFooterStatus.test.ts diff --git a/sake/tests/reader/readerPagination.test.ts b/sake/tests/unit/lib/features/reader/readerPagination.test.ts similarity index 100% rename from sake/tests/reader/readerPagination.test.ts rename to sake/tests/unit/lib/features/reader/readerPagination.test.ts diff --git a/sake/tests/reader/readerPreferences.test.ts b/sake/tests/unit/lib/features/reader/readerPreferences.test.ts similarity index 100% rename from sake/tests/reader/readerPreferences.test.ts rename to sake/tests/unit/lib/features/reader/readerPreferences.test.ts diff --git a/sake/tests/reader/readerSaveQueue.test.ts b/sake/tests/unit/lib/features/reader/readerSaveQueue.test.ts similarity index 100% rename from sake/tests/reader/readerSaveQueue.test.ts rename to sake/tests/unit/lib/features/reader/readerSaveQueue.test.ts diff --git a/sake/tests/reader/readerSessionId.test.ts b/sake/tests/unit/lib/features/reader/readerSessionId.test.ts similarity index 100% rename from sake/tests/reader/readerSessionId.test.ts rename to sake/tests/unit/lib/features/reader/readerSessionId.test.ts diff --git a/sake/tests/reader/readerTapNavigation.test.ts b/sake/tests/unit/lib/features/reader/readerTapNavigation.test.ts similarity index 100% rename from sake/tests/reader/readerTapNavigation.test.ts rename to sake/tests/unit/lib/features/reader/readerTapNavigation.test.ts diff --git a/sake/tests/reader/readerTapNavigationBinding.test.ts b/sake/tests/unit/lib/features/reader/readerTapNavigationBinding.test.ts similarity index 100% rename from sake/tests/reader/readerTapNavigationBinding.test.ts rename to sake/tests/unit/lib/features/reader/readerTapNavigationBinding.test.ts diff --git a/sake/tests/reader/readerWakeLock.test.ts b/sake/tests/unit/lib/features/reader/readerWakeLock.test.ts similarity index 100% rename from sake/tests/reader/readerWakeLock.test.ts rename to sake/tests/unit/lib/features/reader/readerWakeLock.test.ts diff --git a/sake/tests/search/searchView.test.ts b/sake/tests/unit/lib/features/search/searchView.test.ts similarity index 100% rename from sake/tests/search/searchView.test.ts rename to sake/tests/unit/lib/features/search/searchView.test.ts diff --git a/sake/tests/stats/statsView.test.ts b/sake/tests/unit/lib/features/stats/statsView.test.ts similarity index 100% rename from sake/tests/stats/statsView.test.ts rename to sake/tests/unit/lib/features/stats/statsView.test.ts diff --git a/sake/tests/library/epubMetadataService.test.ts b/sake/tests/unit/lib/server/application/services/epubMetadataService.test.ts similarity index 100% rename from sake/tests/library/epubMetadataService.test.ts rename to sake/tests/unit/lib/server/application/services/epubMetadataService.test.ts diff --git a/sake/tests/metadata/externalBookMetadataService.test.ts b/sake/tests/unit/lib/server/application/services/externalBookMetadataService.test.ts similarity index 100% rename from sake/tests/metadata/externalBookMetadataService.test.ts rename to sake/tests/unit/lib/server/application/services/externalBookMetadataService.test.ts diff --git a/sake/tests/integrations/hardcoverProgressSyncService.test.ts b/sake/tests/unit/lib/server/application/services/hardcoverProgressSyncService.test.ts similarity index 100% rename from sake/tests/integrations/hardcoverProgressSyncService.test.ts rename to sake/tests/unit/lib/server/application/services/hardcoverProgressSyncService.test.ts diff --git a/sake/tests/plugin/koreaderPluginVersion.test.ts b/sake/tests/unit/lib/server/application/services/koreaderPluginVersion.test.ts similarity index 100% rename from sake/tests/plugin/koreaderPluginVersion.test.ts rename to sake/tests/unit/lib/server/application/services/koreaderPluginVersion.test.ts diff --git a/sake/tests/library/managedBookCoverService.test.ts b/sake/tests/unit/lib/server/application/services/managedBookCoverService.test.ts similarity index 100% rename from sake/tests/library/managedBookCoverService.test.ts rename to sake/tests/unit/lib/server/application/services/managedBookCoverService.test.ts diff --git a/sake/tests/metadata/metadataDescriptionSanitizer.test.ts b/sake/tests/unit/lib/server/application/services/metadataDescriptionSanitizer.test.ts similarity index 100% rename from sake/tests/metadata/metadataDescriptionSanitizer.test.ts rename to sake/tests/unit/lib/server/application/services/metadataDescriptionSanitizer.test.ts diff --git a/sake/tests/unit/lib/server/application/use-cases/ListDavDirectoryUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/ListDavDirectoryUseCase.test.ts new file mode 100644 index 0000000..1163407 --- /dev/null +++ b/sake/tests/unit/lib/server/application/use-cases/ListDavDirectoryUseCase.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { StoragePort } from '$lib/server/application/ports/StoragePort'; +import { ListDavDirectoryUseCase } from '$lib/server/application/use-cases/ListDavDirectoryUseCase'; + +describe('ListDavDirectoryUseCase', () => { + test('normalizes paths and renders directory objects as WebDAV XML', async () => { + let requestedPrefix = ''; + const storage: StoragePort = { + async put() {}, + async get() { + return Buffer.from(''); + }, + async delete() {}, + async list(prefix) { + requestedPrefix = prefix; + return [{ key: 'library/A & B.epub', size: 42, lastModified: new Date('2026-07-10T00:00:00.000Z') }]; + } + }; + + const result = await new ListDavDirectoryUseCase(storage).execute({ path: '//library//' }); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(requestedPrefix, 'library'); + assert.match(result.value.xml, /\/library<\/D:href>/); + assert.match(result.value.xml, /library%2FA%20%26%20B\.epub/); + assert.match(result.value.xml, /42<\/D:getcontentlength>/); + }); +}); diff --git a/sake/tests/metadata/applyMetadataCandidateUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/applyMetadataCandidateUseCase.test.ts similarity index 100% rename from sake/tests/metadata/applyMetadataCandidateUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/applyMetadataCandidateUseCase.test.ts diff --git a/sake/tests/auth/authCleanup.test.ts b/sake/tests/unit/lib/server/application/use-cases/authCleanup.test.ts similarity index 100% rename from sake/tests/auth/authCleanup.test.ts rename to sake/tests/unit/lib/server/application/use-cases/authCleanup.test.ts diff --git a/sake/tests/logs/deviceLogUseCases.test.ts b/sake/tests/unit/lib/server/application/use-cases/deviceLogUseCases.test.ts similarity index 100% rename from sake/tests/logs/deviceLogUseCases.test.ts rename to sake/tests/unit/lib/server/application/use-cases/deviceLogUseCases.test.ts diff --git a/sake/tests/library/downloadBookUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/downloadBookUseCase.test.ts similarity index 100% rename from sake/tests/library/downloadBookUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/downloadBookUseCase.test.ts diff --git a/sake/tests/library/exportDeviceLibraryBookUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/exportDeviceLibraryBookUseCase.test.ts similarity index 100% rename from sake/tests/library/exportDeviceLibraryBookUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/exportDeviceLibraryBookUseCase.test.ts diff --git a/sake/tests/version/getAppVersionUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/getAppVersionUseCase.test.ts similarity index 100% rename from sake/tests/version/getAppVersionUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/getAppVersionUseCase.test.ts diff --git a/sake/tests/library/getLibraryBookContentUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/getLibraryBookContentUseCase.test.ts similarity index 100% rename from sake/tests/library/getLibraryBookContentUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/getLibraryBookContentUseCase.test.ts diff --git a/sake/tests/queue/getQueueStatusUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/getQueueStatusUseCase.test.ts similarity index 100% rename from sake/tests/queue/getQueueStatusUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/getQueueStatusUseCase.test.ts diff --git a/sake/tests/library/importLibraryBookCoverUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/importLibraryBookCoverUseCase.test.ts similarity index 100% rename from sake/tests/library/importLibraryBookCoverUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/importLibraryBookCoverUseCase.test.ts diff --git a/sake/tests/plugin/koreaderPluginDownloadUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/koreaderPluginDownloadUseCase.test.ts similarity index 100% rename from sake/tests/plugin/koreaderPluginDownloadUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/koreaderPluginDownloadUseCase.test.ts diff --git a/sake/tests/library/libraryCoverLifecycle.test.ts b/sake/tests/unit/lib/server/application/use-cases/libraryCoverLifecycle.test.ts similarity index 100% rename from sake/tests/library/libraryCoverLifecycle.test.ts rename to sake/tests/unit/lib/server/application/use-cases/libraryCoverLifecycle.test.ts diff --git a/sake/tests/library/libraryPublicationDateMetadata.test.ts b/sake/tests/unit/lib/server/application/use-cases/libraryPublicationDateMetadata.test.ts similarity index 100% rename from sake/tests/library/libraryPublicationDateMetadata.test.ts rename to sake/tests/unit/lib/server/application/use-cases/libraryPublicationDateMetadata.test.ts diff --git a/sake/tests/library/putLibraryFileUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/putLibraryFileUseCase.test.ts similarity index 100% rename from sake/tests/library/putLibraryFileUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/putLibraryFileUseCase.test.ts diff --git a/sake/tests/library/putProgressUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/putProgressUseCase.test.ts similarity index 100% rename from sake/tests/library/putProgressUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/putProgressUseCase.test.ts diff --git a/sake/tests/search/queueSearchBookUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/queueSearchBookUseCase.test.ts similarity index 100% rename from sake/tests/search/queueSearchBookUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/queueSearchBookUseCase.test.ts diff --git a/sake/tests/metadata/searchMetadataCandidatesUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/searchMetadataCandidatesUseCase.test.ts similarity index 100% rename from sake/tests/metadata/searchMetadataCandidatesUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/searchMetadataCandidatesUseCase.test.ts diff --git a/sake/tests/library/uploadLibraryBookCoverUseCase.test.ts b/sake/tests/unit/lib/server/application/use-cases/uploadLibraryBookCoverUseCase.test.ts similarity index 100% rename from sake/tests/library/uploadLibraryBookCoverUseCase.test.ts rename to sake/tests/unit/lib/server/application/use-cases/uploadLibraryBookCoverUseCase.test.ts diff --git a/sake/tests/auth/cookies.test.ts b/sake/tests/unit/lib/server/auth/cookies.test.ts similarity index 100% rename from sake/tests/auth/cookies.test.ts rename to sake/tests/unit/lib/server/auth/cookies.test.ts diff --git a/sake/tests/auth/deviceBinding.test.ts b/sake/tests/unit/lib/server/auth/deviceBinding.test.ts similarity index 100% rename from sake/tests/auth/deviceBinding.test.ts rename to sake/tests/unit/lib/server/auth/deviceBinding.test.ts diff --git a/sake/tests/auth/requestAccess.test.ts b/sake/tests/unit/lib/server/auth/requestAccess.test.ts similarity index 100% rename from sake/tests/auth/requestAccess.test.ts rename to sake/tests/unit/lib/server/auth/requestAccess.test.ts diff --git a/sake/tests/config/activatedProviders.test.ts b/sake/tests/unit/lib/server/config/activatedProviders.test.ts similarity index 100% rename from sake/tests/config/activatedProviders.test.ts rename to sake/tests/unit/lib/server/config/activatedProviders.test.ts diff --git a/sake/tests/config/infrastructureConfig.test.ts b/sake/tests/unit/lib/server/config/infrastructureConfig.test.ts similarity index 100% rename from sake/tests/config/infrastructureConfig.test.ts rename to sake/tests/unit/lib/server/config/infrastructureConfig.test.ts diff --git a/sake/tests/search/searchBooksRequest.test.ts b/sake/tests/unit/lib/server/http/searchBooksRequest.test.ts similarity index 100% rename from sake/tests/search/searchBooksRequest.test.ts rename to sake/tests/unit/lib/server/http/searchBooksRequest.test.ts diff --git a/sake/tests/server/http/zlibraryDownloadRequest.test.ts b/sake/tests/unit/lib/server/http/zlibraryDownloadRequest.test.ts similarity index 100% rename from sake/tests/server/http/zlibraryDownloadRequest.test.ts rename to sake/tests/unit/lib/server/http/zlibraryDownloadRequest.test.ts diff --git a/sake/tests/search/zlibraryRequests.test.ts b/sake/tests/unit/lib/server/http/zlibraryRequests.test.ts similarity index 100% rename from sake/tests/search/zlibraryRequests.test.ts rename to sake/tests/unit/lib/server/http/zlibraryRequests.test.ts diff --git a/sake/tests/logs/deviceLogFeed.test.ts b/sake/tests/unit/lib/server/infrastructure/logging/deviceLogFeed.test.ts similarity index 100% rename from sake/tests/logs/deviceLogFeed.test.ts rename to sake/tests/unit/lib/server/infrastructure/logging/deviceLogFeed.test.ts diff --git a/sake/tests/logs/webappLogFeed.test.ts b/sake/tests/unit/lib/server/infrastructure/logging/webappLogFeed.test.ts similarity index 100% rename from sake/tests/logs/webappLogFeed.test.ts rename to sake/tests/unit/lib/server/infrastructure/logging/webappLogFeed.test.ts diff --git a/sake/tests/queue/queuePersistence.test.ts b/sake/tests/unit/lib/server/infrastructure/queue/queuePersistence.test.ts similarity index 100% rename from sake/tests/queue/queuePersistence.test.ts rename to sake/tests/unit/lib/server/infrastructure/queue/queuePersistence.test.ts diff --git a/sake/tests/library/bookRepositoryHelpers.test.ts b/sake/tests/unit/lib/server/infrastructure/repositories/bookRepositoryHelpers.test.ts similarity index 100% rename from sake/tests/library/bookRepositoryHelpers.test.ts rename to sake/tests/unit/lib/server/infrastructure/repositories/bookRepositoryHelpers.test.ts diff --git a/sake/tests/infrastructure/s3Storage.test.ts b/sake/tests/unit/lib/server/infrastructure/storage/S3ListPagination.test.ts similarity index 100% rename from sake/tests/infrastructure/s3Storage.test.ts rename to sake/tests/unit/lib/server/infrastructure/storage/S3ListPagination.test.ts diff --git a/sake/tests/server/createLazySingleton.test.ts b/sake/tests/unit/lib/server/utils/createLazySingleton.test.ts similarity index 100% rename from sake/tests/server/createLazySingleton.test.ts rename to sake/tests/unit/lib/server/utils/createLazySingleton.test.ts diff --git a/sake/tests/logs/logsView.test.ts b/sake/tests/unit/lib/types/Logs/logsView.test.ts similarity index 100% rename from sake/tests/logs/logsView.test.ts rename to sake/tests/unit/lib/types/Logs/logsView.test.ts diff --git a/sake/tests/version/webappVersion.test.ts b/sake/tests/unit/lib/webappVersion.test.ts similarity index 100% rename from sake/tests/version/webappVersion.test.ts rename to sake/tests/unit/lib/webappVersion.test.ts diff --git a/sake/tests/unit/routes/api/opds/feedBuilder.test.ts b/sake/tests/unit/routes/api/opds/feedBuilder.test.ts new file mode 100644 index 0000000..dc710b9 --- /dev/null +++ b/sake/tests/unit/routes/api/opds/feedBuilder.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { Book } from '$lib/server/domain/entities/Book'; +import { renderAcquisitionFeed, renderNavigationFeed } from '../../../../../src/routes/api/opds/feedBuilder'; + +const book: Book = { + id: 7, + zLibId: null, + s3_storage_key: 'library/A & B.epub', + title: 'A < Great > Book', + author: "O'Reilly & Co", + publisher: 'Press "One"', + series: null, + volume: null, + series_index: null, + edition: null, + identifier: null, + pages: null, + description: 'A description with & characters', + google_books_id: null, + open_library_key: null, + hardcover_id: null, + amazon_asin: null, + external_rating: null, + external_rating_count: null, + cover: 'covers/cover image.jpg?book=7&size=large', + extension: 'epub', + filesize: 10, + language: 'en', + year: 2026, + month: 7, + day: 10, + progress_storage_key: null, + progress_updated_at: null, + progress_percent: null, + progress_before_read: null, + rating: null, + read_at: null, + archived_at: null, + exclude_from_new_books: false, + createdAt: '2026-07-10T00:00:00.000Z', + deleted_at: null, + trash_expires_at: null +}; + +describe('OPDS feed builder', () => { + test('escapes acquisition metadata and encodes file and cover links', () => { + const xml = renderAcquisitionFeed('Catalog & More', 'urn:test:', [book], '/api/opds/all'); + + assert.match(xml, /A < Great > Book<\/title>/); + assert.match(xml, /O'Reilly & Co/); + assert.match(xml, /Press "One"/); + assert.match(xml, /A description with <unsafe> & characters/); + assert.match(xml, /download\/library%2FA%20%26%20B\.epub/); + assert.match(xml, /covers\/cover%20image\.jpg\?book=7&size=large/); + assert.match(xml, /type="application\/epub\+zip"/); + assert.match(xml, /image\/jpeg/); + }); + + test('renders navigation entries with escaped labels and descriptions', () => { + const xml = renderNavigationFeed( + 'Root & Catalog', + 'urn:test:root', + [{ title: 'A < shelf', id: 'urn:test:1', url: 'shelf/one & two', description: 'Browse & read' }], + '/api/opds' + ); + + assert.match(xml, /<title>Root & Catalog<\/title>/); + assert.match(xml, /<title>A < shelf<\/title>/); + assert.match(xml, /href="\/api\/opds\/shelf\/one & two"/); + assert.match(xml, /<summary>Browse & read<\/summary>/); + }); +}); From 02b89022fd8c2af1467284ee1b5cdbf880bd9ee4 Mon Sep 17 00:00:00 2001 From: Sascha <salorenz01@proton.me> Date: Fri, 10 Jul 2026 14:35:54 +0200 Subject: [PATCH 2/4] fix: isolate database integration config --- sake/tests/integration/db/bookRepository.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sake/tests/integration/db/bookRepository.test.ts b/sake/tests/integration/db/bookRepository.test.ts index bc493d0..bffef36 100644 --- a/sake/tests/integration/db/bookRepository.test.ts +++ b/sake/tests/integration/db/bookRepository.test.ts @@ -14,6 +14,15 @@ import { mock } from 'bun:test'; const databasePath = join(tmpdir(), `sake-repository-${process.pid}-${randomUUID()}.db`); process.env.LIBSQL_URL = `file:${databasePath}`; process.env.LIBSQL_AUTH_TOKEN = ''; +// BookRepository creates the shared database client through the full infrastructure +// configuration. These values keep the database-only test independent from pipeline +// secrets; no S3 request is made by this suite. +process.env.S3_ENDPOINT = 'http://s3.integration.test'; +process.env.S3_REGION = 'us-east-1'; +process.env.S3_BUCKET = 'integration-test'; +process.env.S3_ACCESS_KEY_ID = 'integration-test'; +process.env.S3_SECRET_ACCESS_KEY = 'integration-test'; +process.env.S3_FORCE_PATH_STYLE = 'true'; mock.module('$env/dynamic/private', () => ({ env: process.env })); const { BookRepository } = await import('$lib/server/infrastructure/repositories/BookRepository'); From 6d4671d136675d438d3c88f7bb0f911723d3ab51 Mon Sep 17 00:00:00 2001 From: Sascha <salorenz01@proton.me> Date: Fri, 10 Jul 2026 14:50:57 +0200 Subject: [PATCH 3/4] fix: add metadata provider config regression tests --- .../metadataAggregatorService.test.ts | 95 +++++++++++++++++++ .../config/activatedMetadataProviders.test.ts | 53 +++++++++++ .../metadataProviderFactory.test.ts | 73 ++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 sake/tests/unit/lib/server/application/services/metadataAggregatorService.test.ts create mode 100644 sake/tests/unit/lib/server/config/activatedMetadataProviders.test.ts create mode 100644 sake/tests/unit/lib/server/infrastructure/metadata-providers/metadataProviderFactory.test.ts diff --git a/sake/tests/unit/lib/server/application/services/metadataAggregatorService.test.ts b/sake/tests/unit/lib/server/application/services/metadataAggregatorService.test.ts new file mode 100644 index 0000000..73b44a4 --- /dev/null +++ b/sake/tests/unit/lib/server/application/services/metadataAggregatorService.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, test } from 'node:test'; +import type { + MetadataCandidate, + MetadataProviderCapabilities, + MetadataProviderPort, + MetadataQuery +} from '$lib/server/application/ports/MetadataProviderPort'; +import { MetadataAggregatorService } from '$lib/server/application/services/MetadataAggregatorService'; +import { apiOk, type ApiResult } from '$lib/server/http/api'; +import type { MetadataProviderId } from '$lib/types/Metadata/Provider'; + +function candidate(providerId: MetadataProviderId): MetadataCandidate { + return { + providerId, + providerScore: 1, + identifiers: { + isbn10: null, + isbn13: null, + asin: null, + googleBooksId: null, + openLibraryKey: null, + hardcoverId: null + }, + title: 'Dune', + subtitle: null, + authors: ['Frank Herbert'], + description: null, + descriptionFormat: 'text', + subjects: [], + series: null, + seriesIndex: null, + publisher: null, + publishedDate: { year: 1965, month: 8, day: 1 }, + language: 'en', + pageCount: 688, + covers: [], + rating: { average: null, count: null }, + sourceUrl: null + }; +} + +class DelayedProvider implements MetadataProviderPort { + readonly capabilities: MetadataProviderCapabilities = { + touchedFields: new Set(['title']), + hasCover: false, + hasRating: false, + requiresIsbn: false + }; + + constructor( + readonly id: MetadataProviderId, + private readonly delayMs: number + ) {} + + async lookup(_query: MetadataQuery): Promise<ApiResult<MetadataCandidate[]>> { + await new Promise((resolve) => setTimeout(resolve, this.delayMs)); + return apiOk([candidate(this.id)]); + } +} + +describe('MetadataAggregatorService', () => { + const originalTimeout = process.env.METADATA_PROVIDER_TIMEOUT_MS; + + afterEach(() => { + if (originalTimeout === undefined) { + delete process.env.METADATA_PROVIDER_TIMEOUT_MS; + } else { + process.env.METADATA_PROVIDER_TIMEOUT_MS = originalTimeout; + } + }); + + test('uses the default timeout when the env override is unset or invalid', async () => { + process.env.METADATA_PROVIDER_TIMEOUT_MS = '0'; + const service = new MetadataAggregatorService([new DelayedProvider('openlibrary', 10)]); + + const result = await service.lookup({ title: 'Dune' }); + + assert.equal(result.providerErrors.length, 0); + assert.equal(result.candidates.length, 1); + assert.equal(result.candidates[0]?.providerId, 'openlibrary'); + }); + + test('uses the configured timeout override for slow providers', async () => { + process.env.METADATA_PROVIDER_TIMEOUT_MS = '1'; + const service = new MetadataAggregatorService([new DelayedProvider('openlibrary', 20)]); + + const result = await service.lookup({ title: 'Dune' }); + + assert.equal(result.candidates.length, 0); + assert.equal(result.providerErrors.length, 1); + assert.equal(result.providerErrors[0]?.providerId, 'openlibrary'); + assert.match(result.providerErrors[0]?.message ?? '', /timed out after 1ms/); + }); +}); diff --git a/sake/tests/unit/lib/server/config/activatedMetadataProviders.test.ts b/sake/tests/unit/lib/server/config/activatedMetadataProviders.test.ts new file mode 100644 index 0000000..066ba99 --- /dev/null +++ b/sake/tests/unit/lib/server/config/activatedMetadataProviders.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +// @ts-expect-error Bun's test-only mock API is available at runtime but excluded from the app tsconfig. +import { mock } from 'bun:test'; + +describe('activatedMetadataProviders', () => { + test('defaults to no providers when env is unset', async () => { + mock.module('$env/dynamic/private', () => ({ env: {} })); + const { parseActivatedMetadataProviders } = await import( + '$lib/server/config/activatedMetadataProviders' + ); + + assert.deepEqual(parseActivatedMetadataProviders(undefined), []); + assert.deepEqual(parseActivatedMetadataProviders(null), []); + }); + + test('supports aliases and removes duplicates while preserving order', async () => { + mock.module('$env/dynamic/private', () => ({ env: {} })); + const { parseActivatedMetadataProviders } = await import( + '$lib/server/config/activatedMetadataProviders' + ); + + assert.deepEqual( + parseActivatedMetadataProviders( + 'google, openlib, hardcover, googlebooks, invalid, isbn, open-library' + ), + ['googlebooks', 'openlibrary', 'hardcover', 'isbndb'] + ); + }); + + test('returns no providers when configured value contains no valid providers', async () => { + mock.module('$env/dynamic/private', () => ({ env: {} })); + const { parseActivatedMetadataProviders } = await import( + '$lib/server/config/activatedMetadataProviders' + ); + + assert.deepEqual(parseActivatedMetadataProviders(' , invalid '), []); + }); + + test('reports metadata lookup enabled state from activated providers env', async () => { + mock.module('$env/dynamic/private', () => ({ + env: { + ACTIVATED_METADATA_PROVIDERS: 'openlib' + } + })); + + const { isMetadataLookupEnabled } = await import( + '$lib/server/config/activatedMetadataProviders' + ); + + assert.equal(isMetadataLookupEnabled(), true); + }); +}); diff --git a/sake/tests/unit/lib/server/infrastructure/metadata-providers/metadataProviderFactory.test.ts b/sake/tests/unit/lib/server/infrastructure/metadata-providers/metadataProviderFactory.test.ts new file mode 100644 index 0000000..bba660b --- /dev/null +++ b/sake/tests/unit/lib/server/infrastructure/metadata-providers/metadataProviderFactory.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, test } from 'node:test'; +import { GoogleBooksMetadataProvider } from '$lib/server/infrastructure/metadata-providers/googleBooksMetadataProvider'; +import { HardcoverMetadataProvider } from '$lib/server/infrastructure/metadata-providers/hardcoverMetadataProvider'; +import { IsbnDbMetadataProvider } from '$lib/server/infrastructure/metadata-providers/isbndbMetadataProvider'; +import { OpenLibraryMetadataProvider } from '$lib/server/infrastructure/metadata-providers/openLibraryMetadataProvider'; +import { + createMetadataProvider, + createMetadataProviders +} from '$lib/server/infrastructure/metadata-providers/metadataProviderFactory'; + +describe('metadataProviderFactory', () => { + const originalHardcoverToken = process.env.HARDCOVER_API_TOKEN; + const originalIsbnDbKey = process.env.ISBNDB_API_KEY; + + afterEach(() => { + if (originalHardcoverToken === undefined) { + delete process.env.HARDCOVER_API_TOKEN; + } else { + process.env.HARDCOVER_API_TOKEN = originalHardcoverToken; + } + + if (originalIsbnDbKey === undefined) { + delete process.env.ISBNDB_API_KEY; + } else { + process.env.ISBNDB_API_KEY = originalIsbnDbKey; + } + }); + + test('creates providers that do not require credentials', () => { + assert.ok(createMetadataProvider('googlebooks') instanceof GoogleBooksMetadataProvider); + assert.ok(createMetadataProvider('openlibrary') instanceof OpenLibraryMetadataProvider); + }); + + test('skips Hardcover creation when the token is missing or blank', () => { + delete process.env.HARDCOVER_API_TOKEN; + assert.equal(createMetadataProvider('hardcover'), null); + + process.env.HARDCOVER_API_TOKEN = ' '; + assert.equal(createMetadataProvider('hardcover'), null); + + process.env.HARDCOVER_API_TOKEN = 'test-token'; + assert.ok(createMetadataProvider('hardcover') instanceof HardcoverMetadataProvider); + }); + + test('skips ISBNdb creation when the API key is missing or blank', () => { + delete process.env.ISBNDB_API_KEY; + assert.equal(createMetadataProvider('isbndb'), null); + + process.env.ISBNDB_API_KEY = ' '; + assert.equal(createMetadataProvider('isbndb'), null); + + process.env.ISBNDB_API_KEY = 'test-key'; + assert.ok(createMetadataProvider('isbndb') instanceof IsbnDbMetadataProvider); + }); + + test('filters out providers that cannot be created from the activated list', () => { + delete process.env.HARDCOVER_API_TOKEN; + process.env.ISBNDB_API_KEY = 'test-key'; + + const providers = createMetadataProviders([ + 'googlebooks', + 'hardcover', + 'openlibrary', + 'isbndb' + ]); + + assert.deepEqual( + providers.map((provider) => provider.id), + ['googlebooks', 'openlibrary', 'isbndb'] + ); + }); +}); From a0fbd3f916f298926da0970110f8e82e9962b870 Mon Sep 17 00:00:00 2001 From: Sascha <salorenz01@proton.me> Date: Fri, 10 Jul 2026 15:25:37 +0200 Subject: [PATCH 4/4] fix: harden external client resilience --- .../services/HardcoverProgressSyncService.ts | 4 +- .../infrastructure/clients/HardcoverClient.ts | 56 ++++-------- .../infrastructure/clients/ZLibraryClient.ts | 55 +++++++++--- .../clients/externalClientPolicy.ts | 86 +++++++++++++++++++ .../infrastructure/queue/downloadQueue.ts | 28 +++--- .../clients/hardcoverClient.test.ts | 2 +- .../clients/zLibraryClient.test.ts | 44 ++++++++++ .../hardcoverProgressSyncService.test.ts | 2 +- .../queue/downloadQueue.test.ts | 29 +++++++ 9 files changed, 233 insertions(+), 73 deletions(-) create mode 100644 sake/src/lib/server/infrastructure/clients/externalClientPolicy.ts create mode 100644 sake/tests/contract/server/infrastructure/clients/zLibraryClient.test.ts create mode 100644 sake/tests/unit/lib/server/infrastructure/queue/downloadQueue.test.ts diff --git a/sake/src/lib/server/application/services/HardcoverProgressSyncService.ts b/sake/src/lib/server/application/services/HardcoverProgressSyncService.ts index 587e49e..dbb67f6 100644 --- a/sake/src/lib/server/application/services/HardcoverProgressSyncService.ts +++ b/sake/src/lib/server/application/services/HardcoverProgressSyncService.ts @@ -174,7 +174,7 @@ export function describeHardcoverSyncFailure(cause: unknown): string { if (cause.kind === 'authentication') { return 'Hardcover rejected the configured API token. Update HARDCOVER_API_TOKEN, restart Sake, and retry.'; } - if (cause.kind === 'rate-limit') { + if (cause.kind === 'rate_limit') { return 'Hardcover rate limit reached. Sake will retry this job automatically.'; } if (cause.kind === 'timeout') { @@ -183,7 +183,7 @@ export function describeHardcoverSyncFailure(cause: unknown): string { if (cause.kind === 'network') { return 'Sake could not reach Hardcover. Check the server network connection; Sake will retry automatically.'; } - if (cause.kind === 'invalid-response') { + if (cause.kind === 'invalid_response') { return 'Hardcover returned an incomplete response. Sake will retry this job automatically.'; } if (cause.kind === 'configuration') { diff --git a/sake/src/lib/server/infrastructure/clients/HardcoverClient.ts b/sake/src/lib/server/infrastructure/clients/HardcoverClient.ts index 4eda206..f3cad8b 100644 --- a/sake/src/lib/server/infrastructure/clients/HardcoverClient.ts +++ b/sake/src/lib/server/infrastructure/clients/HardcoverClient.ts @@ -3,25 +3,23 @@ const UPSTREAM_TIMEOUT_MS = 30_000; const USER_AGENT = 'Sake/1.0 (+https://github.com/Sudashiii/Sake)'; const RATE_LIMIT_INTERVAL_MS = 1_000; -export type HardcoverClientErrorKind = - | 'authentication' - | 'rate-limit' - | 'upstream' - | 'graphql' - | 'timeout' - | 'network' - | 'invalid-response' - | 'mutation' - | 'configuration'; +import { + ExternalClientError, + parseExternalJson, + requestExternal, + type ExternalClientErrorKind +} from './externalClientPolicy'; -export class HardcoverClientError extends Error { +export type HardcoverClientErrorKind = ExternalClientErrorKind; + +export class HardcoverClientError extends ExternalClientError { constructor( message: string, readonly status: number, readonly isRetryable: boolean, readonly kind: HardcoverClientErrorKind = 'upstream' ) { - super(message); + super(message, status, isRetryable, kind); this.name = 'HardcoverClientError'; } } @@ -41,12 +39,10 @@ export class HardcoverClient { } this.nextAllowedAt = Date.now() + RATE_LIMIT_INTERVAL_MS; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS); try { - const response = await this.fetchFn(HARDCOVER_API_URL, { + const response = await requestExternal(this.fetchFn, HARDCOVER_API_URL, { method: 'POST', - signal: controller.signal, + timeoutMs: UPSTREAM_TIMEOUT_MS, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiToken}`, @@ -55,25 +51,11 @@ export class HardcoverClient { body: JSON.stringify({ query, variables }) }); - if (!response.ok) { - const kind: HardcoverClientErrorKind = - response.status === 401 || response.status === 403 - ? 'authentication' - : response.status === 429 - ? 'rate-limit' - : 'upstream'; - throw new HardcoverClientError( - `Hardcover API returned HTTP ${response.status}`, - response.status, - response.status === 429 || response.status >= 500, - kind - ); - } - - const payload = (await response.json()) as { + const payload = await parseExternalJson(response, (value): value is { data?: T; errors?: Array<{ message?: string }>; - }; + } => typeof value === 'object' && value !== null && + ('data' in value || 'errors' in value)); if (payload.errors?.length) { const message = payload.errors.map((error) => error.message ?? 'Unknown GraphQL error').join('; '); throw new HardcoverClientError( @@ -84,19 +66,17 @@ export class HardcoverClient { ); } if (payload.data === undefined) { - throw new HardcoverClientError('Hardcover API returned no data', 502, true, 'invalid-response'); + throw new HardcoverClientError('Hardcover API returned no data', 502, true, 'invalid_response'); } return payload.data; } catch (cause: unknown) { if (cause instanceof HardcoverClientError) { throw cause; } - if (cause instanceof Error && cause.name === 'AbortError') { - throw new HardcoverClientError('Hardcover request timed out', 504, true, 'timeout'); + if (cause instanceof ExternalClientError) { + throw new HardcoverClientError(cause.message, cause.status, cause.isRetryable, cause.kind); } throw new HardcoverClientError('Hardcover request failed', 502, true, 'network'); - } finally { - clearTimeout(timer); } } } diff --git a/sake/src/lib/server/infrastructure/clients/ZLibraryClient.ts b/sake/src/lib/server/infrastructure/clients/ZLibraryClient.ts index 493b8f1..4765d41 100644 --- a/sake/src/lib/server/infrastructure/clients/ZLibraryClient.ts +++ b/sake/src/lib/server/infrastructure/clients/ZLibraryClient.ts @@ -5,11 +5,16 @@ import type { ZLibraryCredentials, ZLibraryPort, ZLibrarySearchRequest } from '$ import { toUrlEncoded } from '$lib/server/infrastructure/clients/toUrlEncode'; import type { ZLoginRequest } from '$lib/types/ZLibrary/Requests/ZLoginRequest'; import { apiError, apiOk, type ApiResult } from '$lib/server/http/api'; +import { + ExternalClientError, + parseExternalJson, + requestExternal +} from '$lib/server/infrastructure/clients/externalClientPolicy'; export class ZLibraryClient implements ZLibraryPort { private readonly baseUrl: string; - constructor(baseUrl: string) { + constructor(baseUrl: string, private readonly fetchFn: typeof fetch = fetch) { this.baseUrl = baseUrl; } @@ -40,7 +45,7 @@ export class ZLibraryClient implements ZLibraryPort { let fileInfo: ZBookFileResponse; try { - fileInfo = (await fileInfoResponse.value.json()) as ZBookFileResponse; + fileInfo = await parseExternalJson(fileInfoResponse.value, isZBookFileResponse); } catch (cause) { return apiError('Failed to parse download file info', 502, cause); } @@ -101,8 +106,9 @@ export class ZLibraryClient implements ZLibraryPort { private async get(path: string, credentials?: ZLibraryCredentials): Promise<ApiResult<Response>> { try { - const response = await fetch(this.baseUrl + path, { + const response = await requestExternal(this.fetchFn, this.baseUrl + path, { method: 'GET', + timeoutMs: 30_000, headers: this.getHeaders(credentials) }); @@ -112,14 +118,15 @@ export class ZLibraryClient implements ZLibraryPort { return apiOk(response); } catch (cause) { - return apiError('Failed to execute GET request', 502, cause); + return apiError('Failed to execute GET request', getExternalStatus(cause), cause); } } private async getAbsolute(url: string, credentials?: ZLibraryCredentials): Promise<ApiResult<Response>> { try { - const response = await fetch(url, { + const response = await requestExternal(this.fetchFn, url, { method: 'GET', + timeoutMs: 30_000, headers: this.getHeaders(credentials) }); @@ -129,7 +136,7 @@ export class ZLibraryClient implements ZLibraryPort { return apiOk(response); } catch (cause) { - return apiError('Failed to execute GET request', 502, cause); + return apiError('Failed to execute GET request', getExternalStatus(cause), cause); } } @@ -139,24 +146,46 @@ export class ZLibraryClient implements ZLibraryPort { credentials?: ZLibraryCredentials ): Promise<ApiResult<T>> { try { - const response = await fetch(this.baseUrl + path, { + const response = await requestExternal(this.fetchFn, this.baseUrl + path, { method: 'POST', + timeoutMs: 30_000, headers: this.getHeaders(credentials), body: toUrlEncoded(data) }); - if (!response.ok) { - return apiError(`Request failed with status ${response.status}`, response.status); - } - - const parsed = (await response.json()) as T; + const parsed = await parseExternalJson(response, (value): value is T => { + if (path === ZLibraryRoutes.search) return isZSearchBookResponse(value); + if (path === ZLibraryRoutes.passwordLogin) return isZLoginResponse(value); + return typeof value === 'object' && value !== null; + }); return apiOk(parsed); } catch (cause) { - return apiError('Failed to execute POST request', 502, cause); + return apiError('Failed to execute POST request', getExternalStatus(cause), cause); } } } +function getExternalStatus(cause: unknown): number { + return cause instanceof ExternalClientError ? cause.status : 502; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null; +} + +function isZBookFileResponse(value: unknown): value is ZBookFileResponse { + if (!isRecord(value) || !isRecord(value.file)) return false; + return typeof value.success === 'number' && typeof value.file.downloadLink === 'string'; +} + +function isZSearchBookResponse(value: unknown): value is ZSearchBookResponse { + return isRecord(value) && typeof value.success === 'number' && Array.isArray(value.books); +} + +function isZLoginResponse(value: unknown): value is ZLoginResponse { + return isRecord(value) && (value.success === 0 || value.success === 1) && isRecord(value.user); +} + const ZLibraryRoutes: Record<string, string> = { passwordLogin: '/eapi/user/login', profile: '/eapi/user/profile', diff --git a/sake/src/lib/server/infrastructure/clients/externalClientPolicy.ts b/sake/src/lib/server/infrastructure/clients/externalClientPolicy.ts new file mode 100644 index 0000000..4061f27 --- /dev/null +++ b/sake/src/lib/server/infrastructure/clients/externalClientPolicy.ts @@ -0,0 +1,86 @@ +export type ExternalClientErrorKind = + | 'timeout' + | 'rate_limit' + | 'authentication' + | 'invalid_response' + | 'upstream' + | 'network' + | 'graphql' + | 'mutation' + | 'configuration'; + +export class ExternalClientError extends Error { + constructor( + message: string, + readonly status: number, + readonly isRetryable: boolean, + readonly kind: ExternalClientErrorKind = 'upstream' + ) { + super(message); + this.name = 'ExternalClientError'; + } +} + +export interface ExternalRequestOptions extends RequestInit { + timeoutMs: number; +} + +export async function requestExternal( + fetchFn: typeof fetch, + input: RequestInfo | URL, + options: ExternalRequestOptions +): Promise<Response> { + const controller = new AbortController(); + const { timeoutMs, ...requestInit } = options; + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchFn(input, { ...requestInit, signal: controller.signal }); + if (!response.ok) { + throw new ExternalClientError( + `External API returned HTTP ${response.status}`, + response.status, + response.status === 429 || response.status >= 500, + classifyStatus(response.status) + ); + } + return response; + } catch (cause: unknown) { + if (cause instanceof ExternalClientError) { + throw cause; + } + if (cause instanceof Error && cause.name === 'AbortError') { + throw new ExternalClientError('External request timed out', 504, true, 'timeout'); + } + throw new ExternalClientError('External request failed', 502, true, 'network'); + } finally { + clearTimeout(timer); + } +} + +export async function parseExternalJson<T>( + response: Response, + validate: (value: unknown) => value is T, + maxBytes = 1_048_576 +): Promise<T> { + const body = await response.text(); + if (new TextEncoder().encode(body).byteLength > maxBytes) { + throw new ExternalClientError('External API response was too large', 502, false, 'invalid_response'); + } + + let value: unknown; + try { + value = JSON.parse(body) as unknown; + } catch { + throw new ExternalClientError('External API returned invalid JSON', 502, false, 'invalid_response'); + } + if (!validate(value)) { + throw new ExternalClientError('External API returned an invalid response', 502, false, 'invalid_response'); + } + return value; +} + +function classifyStatus(status: number): ExternalClientErrorKind { + if (status === 401 || status === 403) return 'authentication'; + if (status === 429) return 'rate_limit'; + return 'upstream'; +} diff --git a/sake/src/lib/server/infrastructure/queue/downloadQueue.ts b/sake/src/lib/server/infrastructure/queue/downloadQueue.ts index e56f59b..c1ca269 100644 --- a/sake/src/lib/server/infrastructure/queue/downloadQueue.ts +++ b/sake/src/lib/server/infrastructure/queue/downloadQueue.ts @@ -15,6 +15,7 @@ import type { ZLibraryQueueTaskInput } from '$lib/server/application/ports/DownloadQueuePort'; import { randomUUID } from 'node:crypto'; +import { ExternalClientError } from '$lib/server/infrastructure/clients/externalClientPolicy'; interface BaseQueuedDownload { id: string; @@ -274,9 +275,9 @@ export class DownloadQueue { return; } - const canRetry = this.isRetryableFailure( + const canRetry = isRetryableExternalFailure( useCaseResult.error.status, - useCaseResult.error.message + useCaseResult.error.cause ); const isLastAttempt = attempt === task.maxAttempts; if (!canRetry || isLastAttempt) { @@ -369,22 +370,6 @@ export class DownloadQueue { return `${normalizedTitle}.${extension}`; } - private isRetryableFailure(statusCode: number, message: string): boolean { - if (statusCode === 429 || statusCode >= 500) { - return true; - } - - const normalized = message.toLowerCase(); - return ( - normalized.includes('terminated') || - normalized.includes('timeout') || - normalized.includes('econnreset') || - normalized.includes('network') || - normalized.includes('failed to execute get request') || - normalized.includes('failed to execute post request') - ); - } - private getRetryDelayMs(attempt: number): number { // 500ms, 1000ms, 2000ms... return 500 * 2 ** (attempt - 1); @@ -478,3 +463,10 @@ export class DownloadQueue { return copy.buffer; } } + +export function isRetryableExternalFailure(statusCode: number, cause: unknown): boolean { + if (cause instanceof ExternalClientError) { + return cause.isRetryable; + } + return statusCode === 429 || statusCode >= 500; +} diff --git a/sake/tests/contract/server/infrastructure/clients/hardcoverClient.test.ts b/sake/tests/contract/server/infrastructure/clients/hardcoverClient.test.ts index 85cbfe7..667c190 100644 --- a/sake/tests/contract/server/infrastructure/clients/hardcoverClient.test.ts +++ b/sake/tests/contract/server/infrastructure/clients/hardcoverClient.test.ts @@ -24,7 +24,7 @@ describe('HardcoverClient', () => { (error: unknown) => error instanceof HardcoverClientError && error.status === 429 && - error.kind === 'rate-limit' && + error.kind === 'rate_limit' && error.isRetryable ); }); diff --git a/sake/tests/contract/server/infrastructure/clients/zLibraryClient.test.ts b/sake/tests/contract/server/infrastructure/clients/zLibraryClient.test.ts new file mode 100644 index 0000000..13573b8 --- /dev/null +++ b/sake/tests/contract/server/infrastructure/clients/zLibraryClient.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { ExternalClientError } from '$lib/server/infrastructure/clients/externalClientPolicy'; +import { ZLibraryClient } from '$lib/server/infrastructure/clients/ZLibraryClient'; + +describe('ZLibraryClient', () => { + test('classifies authentication responses without retrying', async () => { + const client = new ZLibraryClient('https://z.example', async () => new Response(null, { status: 401 })); + const result = await client.search({ searchText: 'book' }); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.error.status, 401); + assert.ok(result.error.cause instanceof ExternalClientError); + assert.equal(result.error.cause.kind, 'authentication'); + assert.equal(result.error.cause.isRetryable, false); + }); + + test('rejects malformed JSON as a non-retryable invalid response', async () => { + const client = new ZLibraryClient('https://z.example', async () => + new Response('{not-json', { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); + const result = await client.search({ searchText: 'book' }); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.ok(result.error.cause instanceof ExternalClientError); + assert.equal(result.error.cause.kind, 'invalid_response'); + assert.equal(result.error.cause.isRetryable, false); + }); + + test('classifies aborted requests as retryable timeouts', async () => { + const client = new ZLibraryClient('https://z.example', async () => { + throw new DOMException('aborted', 'AbortError'); + }); + const result = await client.search({ searchText: 'book' }); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.ok(result.error.cause instanceof ExternalClientError); + assert.equal(result.error.cause.kind, 'timeout'); + assert.equal(result.error.cause.isRetryable, true); + }); +}); diff --git a/sake/tests/unit/lib/server/application/services/hardcoverProgressSyncService.test.ts b/sake/tests/unit/lib/server/application/services/hardcoverProgressSyncService.test.ts index dd07048..a7ee9c6 100644 --- a/sake/tests/unit/lib/server/application/services/hardcoverProgressSyncService.test.ts +++ b/sake/tests/unit/lib/server/application/services/hardcoverProgressSyncService.test.ts @@ -153,7 +153,7 @@ describe('HardcoverProgressSyncService', () => { ); assert.match( describeHardcoverSyncFailure( - new HardcoverClientError('Hardcover API returned HTTP 429', 429, true, 'rate-limit') + new HardcoverClientError('Hardcover API returned HTTP 429', 429, true, 'rate_limit') ), /retry.*automatically/i ); diff --git a/sake/tests/unit/lib/server/infrastructure/queue/downloadQueue.test.ts b/sake/tests/unit/lib/server/infrastructure/queue/downloadQueue.test.ts new file mode 100644 index 0000000..3735cb0 --- /dev/null +++ b/sake/tests/unit/lib/server/infrastructure/queue/downloadQueue.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { ExternalClientError } from '$lib/server/infrastructure/clients/externalClientPolicy'; +import { isRetryableExternalFailure } from '$lib/server/infrastructure/queue/downloadQueue'; + +describe('download queue retry classification', () => { + test('uses structured external causes instead of their messages', () => { + assert.equal( + isRetryableExternalFailure( + 502, + new ExternalClientError('arbitrary message', 502, false, 'invalid_response') + ), + false + ); + assert.equal( + isRetryableExternalFailure( + 400, + new ExternalClientError('arbitrary message', 504, true, 'timeout') + ), + true + ); + }); + + test('retains status-based retries for unstructured upstream failures', () => { + assert.equal(isRetryableExternalFailure(429, undefined), true); + assert.equal(isRetryableExternalFailure(503, undefined), true); + assert.equal(isRetryableExternalFailure(400, new Error('timeout')), false); + }); +});