diff --git a/.github/workflows/build-unified-image.yml b/.github/workflows/build-unified-image.yml index 23de219..35f8bb9 100644 --- a/.github/workflows/build-unified-image.yml +++ b/.github/workflows/build-unified-image.yml @@ -13,7 +13,14 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + test: + name: Run Tests + uses: ./.github/workflows/run-tests.yml + secrets: + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + build-and-push: + needs: test runs-on: ubuntu-latest permissions: contents: read @@ -94,3 +101,43 @@ jobs: echo " -v readmeabook-data:/var/lib/postgresql/data \\" >> $GITHUB_STEP_SUMMARY echo " ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Send Discord notification + if: github.event_name != 'pull_request' && success() + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d '{ + "embeds": [{ + "title": "๐Ÿ“ฆ Docker Image Published", + "description": "A new version of **ReadMeABook** has been built and published to GitHub Container Registry.", + "color": 5763719, + "fields": [ + { + "name": "๐Ÿท๏ธ Image Tag", + "value": "`sha-${{ steps.version.outputs.git_commit }}`", + "inline": true + }, + { + "name": "๐ŸŒฟ Branch", + "value": "`${{ github.ref_name }}`", + "inline": true + }, + { + "name": "๐Ÿ“‹ Commit", + "value": "[`${{ steps.version.outputs.git_commit }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})", + "inline": true + }, + { + "name": "๐Ÿ“ฅ Pull Command", + "value": "```docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.git_commit }}```", + "inline": false + } + ], + "footer": { + "text": "ReadMeABook CI/CD โ€ข Built with GitHub Actions" + }, + "timestamp": "${{ steps.version.outputs.build_date }}" + }] + }' \ + ${{ secrets.WEBHOOK_URL }} diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..a89707b --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,201 @@ +name: Backend Tests + +on: + workflow_dispatch: + workflow_call: + inputs: + send_notification: + description: "Whether to send Discord notification" + type: boolean + default: true + secrets: + WEBHOOK_URL: + description: "Discord webhook URL" + required: false + outputs: + success: + description: "Whether tests passed" + value: ${{ jobs.test.outputs.success }} + total: + description: "Total number of tests" + value: ${{ jobs.test.outputs.total }} + passed: + description: "Number of passed tests" + value: ${{ jobs.test.outputs.passed }} + failed: + description: "Number of failed tests" + value: ${{ jobs.test.outputs.failed }} + duration: + description: "Test duration in seconds" + value: ${{ jobs.test.outputs.duration }} + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + outputs: + success: ${{ steps.test-results.outputs.success }} + total: ${{ steps.test-results.outputs.total }} + passed: ${{ steps.test-results.outputs.passed }} + failed: ${{ steps.test-results.outputs.failed }} + duration: ${{ steps.test-results.outputs.duration }} + test_files: ${{ steps.test-results.outputs.test_files }} + test_files_passed: ${{ steps.test-results.outputs.test_files_passed }} + test_files_failed: ${{ steps.test-results.outputs.test_files_failed }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + id: run-tests + continue-on-error: true + run: | + START_TIME=$(date +%s) + npm test -- --reporter=json --outputFile=test-results.json 2>&1 | tee test-output.txt + END_TIME=$(date +%s) + echo "exit_code=$?" >> $GITHUB_OUTPUT + echo "start_time=$(date -u +"%H:%M:%S")" >> $GITHUB_OUTPUT + echo "elapsed=$((END_TIME - START_TIME))" >> $GITHUB_OUTPUT + + - name: Parse test results + id: test-results + if: always() + run: | + if [ -f test-results.json ]; then + TOTAL=$(jq '.numTotalTests // 0' test-results.json) + PASSED=$(jq '.numPassedTests // 0' test-results.json) + FAILED=$(jq '.numFailedTests // 0' test-results.json) + TEST_FILES=$(jq '.numTotalTestSuites // 0' test-results.json) + TEST_FILES_PASSED=$(jq '.numPassedTestSuites // 0' test-results.json) + TEST_FILES_FAILED=$(jq '.numFailedTestSuites // 0' test-results.json) + DURATION=$(jq '((.testResults | map(.endTime) | max) - (.testResults | map(.startTime) | min)) / 1000 | . * 100 | floor / 100' test-results.json 2>/dev/null || echo "0") + SUCCESS=$([ "$FAILED" -eq 0 ] && echo "true" || echo "false") + else + TOTAL=0 + PASSED=0 + FAILED=1 + TEST_FILES=0 + TEST_FILES_PASSED=0 + TEST_FILES_FAILED=1 + DURATION=0 + SUCCESS="false" + fi + + echo "total=$TOTAL" >> $GITHUB_OUTPUT + echo "passed=$PASSED" >> $GITHUB_OUTPUT + echo "failed=$FAILED" >> $GITHUB_OUTPUT + echo "duration=$DURATION" >> $GITHUB_OUTPUT + echo "success=$SUCCESS" >> $GITHUB_OUTPUT + echo "test_files=$TEST_FILES" >> $GITHUB_OUTPUT + echo "test_files_passed=$TEST_FILES_PASSED" >> $GITHUB_OUTPUT + echo "test_files_failed=$TEST_FILES_FAILED" >> $GITHUB_OUTPUT + + echo "## ๐Ÿงช Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$SUCCESS" = "true" ]; then + echo "### โœ… All tests passed!" >> $GITHUB_STEP_SUMMARY + else + echo "### โŒ Some tests failed" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY + echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Test Files | $TEST_FILES_PASSED passed ($TEST_FILES) |" >> $GITHUB_STEP_SUMMARY + echo "| Tests | $PASSED passed ($TOTAL) |" >> $GITHUB_STEP_SUMMARY + echo "| Duration | ${DURATION}s |" >> $GITHUB_STEP_SUMMARY + + - name: Send Discord notification + if: always() && (inputs.send_notification != false) + env: + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + run: | + if [ -z "$WEBHOOK_URL" ]; then + echo "No webhook URL provided, skipping notification" + exit 0 + fi + + SUCCESS="${{ steps.test-results.outputs.success }}" + TOTAL="${{ steps.test-results.outputs.total }}" + PASSED="${{ steps.test-results.outputs.passed }}" + FAILED="${{ steps.test-results.outputs.failed }}" + DURATION="${{ steps.test-results.outputs.duration }}" + TEST_FILES="${{ steps.test-results.outputs.test_files }}" + TEST_FILES_PASSED="${{ steps.test-results.outputs.test_files_passed }}" + TEST_FILES_FAILED="${{ steps.test-results.outputs.test_files_failed }}" + + if [ "$SUCCESS" = "true" ]; then + COLOR=5763719 + TITLE="โœ… Tests Passed" + DESCRIPTION="All tests completed successfully for **ReadMeABook**" + TEST_FILES_VALUE="$TEST_FILES_PASSED passed ($TEST_FILES)" + TESTS_VALUE="$PASSED passed ($TOTAL)" + else + COLOR=15548997 + TITLE="โŒ Tests Failed" + DESCRIPTION="Some tests failed for **ReadMeABook**" + TEST_FILES_VALUE="$TEST_FILES_PASSED passed, $TEST_FILES_FAILED failed ($TEST_FILES)" + TESTS_VALUE="$PASSED passed, $FAILED failed ($TOTAL)" + fi + + curl -H "Content-Type: application/json" \ + -X POST \ + -d "{ + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESCRIPTION\", + \"color\": $COLOR, + \"fields\": [ + { + \"name\": \"๐Ÿ“ Test Files\", + \"value\": \"\`$TEST_FILES_VALUE\`\", + \"inline\": true + }, + { + \"name\": \"๐Ÿงช Tests\", + \"value\": \"\`$TESTS_VALUE\`\", + \"inline\": true + }, + { + \"name\": \"โฑ๏ธ Duration\", + \"value\": \"\`${DURATION}s\`\", + \"inline\": true + }, + { + \"name\": \"๐ŸŒฟ Branch\", + \"value\": \"\`${{ github.ref_name }}\`\", + \"inline\": true + }, + { + \"name\": \"๐Ÿ“‹ Commit\", + \"value\": \"[\`$(echo ${{ github.sha }} | cut -c1-7)\`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})\", + \"inline\": true + }, + { + \"name\": \"๐Ÿ”— Workflow\", + \"value\": \"[View Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})\", + \"inline\": true + } + ], + \"footer\": { + \"text\": \"ReadMeABook CI/CD โ€ข Test Suite\" + }, + \"timestamp\": \"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\" + }] + }" \ + "$WEBHOOK_URL" + + - name: Fail if tests failed + if: steps.test-results.outputs.success != 'true' + run: exit 1 diff --git a/documentation/README.md b/documentation/README.md index 5ae9e8b..c51c6a7 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -66,6 +66,9 @@ External integrations: Plex (auth + library), Prowlarr/Jackett (indexers), qBitt **Deployment:** - [deployment/docker.md](deployment/docker.md) - Docker Compose, volumes, env vars +**Testing:** +- [testing.md](testing.md) - Backend unit tests, scripts + ## Development Phases โœ… Phase 1: Foundation (auth, database, setup wizard) โœ… Phase 2: User features (discovery, requests, dashboard) diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 5f7a670..6a40c3c 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -11,7 +11,8 @@ ## Configuration & Setup - **First-time setup wizard** โ†’ [setup-wizard.md](setup-wizard.md) - **Settings management, encryption** โ†’ [backend/services/config.md](backend/services/config.md) -- **Settings UI (Plex, Prowlarr, paths)** โ†’ [settings-pages.md](settings-pages.md) +- **Settings UI (modular architecture, all tabs)** โ†’ [settings-pages.md](settings-pages.md) +- **Settings architecture refactoring (Jan 2026)** โ†’ [settings-pages.md](settings-pages.md#architecture-refactored-jan-2026) - **Setup middleware & status check** โ†’ [backend/middleware.md](backend/middleware.md) - **Environment variables, PUBLIC_URL, OAuth configuration** โ†’ [backend/services/environment.md](backend/services/environment.md) @@ -81,6 +82,9 @@ - **Environment variables, volumes** โ†’ [deployment/docker.md](deployment/docker.md) - **Database setup (Prisma), migrations** โ†’ [deployment/docker.md](deployment/docker.md) +## Testing +- **Backend unit test framework, scripts** [testing.md](testing.md) + ## Feature-Specific Lookups **"How do I add a new audiobook?"** โ†’ [integrations/audible.md](integrations/audible.md) (scraping), [phase3/README.md](phase3/README.md) (automation) **"How do torrent downloads work?"** โ†’ [phase3/qbittorrent.md](phase3/qbittorrent.md), [backend/services/jobs.md](backend/services/jobs.md) diff --git a/documentation/features/bookdate.md b/documentation/features/bookdate.md index 12d50e8..057b6f1 100644 --- a/documentation/features/bookdate.md +++ b/documentation/features/bookdate.md @@ -390,6 +390,22 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI p - Consistent with recommendations endpoint filtering behavior - Files updated: `src/app/api/bookdate/generate/route.ts:147-157` +**8. Test Connection with Bad Custom LLM Credentials Logs User Out** +- Issue: Testing custom LLM connection with invalid credentials causes user to be logged out instead of showing error +- User Experience: "When I test connection with wrong API key, I get logged out of ReadMeABook instead of seeing an error message" +- Cause: Custom LLM provider returns 401 Unauthorized for invalid credentials + - Test-connection endpoint passed through external service's 401 status code + - `fetchWithAuth()` utility intercepts ALL 401 responses, assuming they indicate expired user session + - Triggers automatic token refresh, then logout if refresh fails or still 401 + - User logged out of application when only external service credentials were invalid +- Fix: Return 400 Bad Request instead of passing through external service status codes + - Changed lines 196, 387 in `src/app/api/bookdate/test-connection/route.ts` + - Now returns `{ status: 400 }` for all custom provider connection failures + - Reserve 401 status exclusively for application authentication issues + - External service credential failures are client errors (400), not auth errors (401) + - Added tests to verify 401 from external provider returns 400 to client +- Files updated: `src/app/api/bookdate/test-connection/route.ts:190-197,382-389`, `tests/api/bookdate-test-connection.routes.test.ts:254-294` + ## Related - Full requirements: [features/bookdate-prd.md](bookdate-prd.md) diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index d642a46..722943a 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -1,9 +1,67 @@ # Settings Pages -**Status:** โœ… Implemented +**Status:** โœ… Implemented | โ™ป๏ธ Refactored (Jan 2026) Single tabbed interface for admins to view/modify system configuration post-setup with mandatory validation before saving. +## Architecture (Refactored Jan 2026) + +**Original:** Monolithic 2,971-line component +**Current:** Modular architecture with 89% code reduction (2,971 โ†’ 325 lines) + +**Structure:** +``` +src/app/admin/settings/ +โ”œโ”€โ”€ page.tsx # Shell component (325 lines) +โ”œโ”€โ”€ lib/ +โ”‚ โ”œโ”€โ”€ types.ts # Shared TypeScript interfaces +โ”‚ โ””โ”€โ”€ helpers.ts # Business logic (206 lines) +โ”œโ”€โ”€ hooks/ +โ”‚ โ””โ”€โ”€ useSettings.ts # Global settings hook +โ””โ”€โ”€ tabs/ # Feature modules + โ”œโ”€โ”€ LibraryTab/ # Plex/Audiobookshelf config + โ”‚ โ”œโ”€โ”€ LibraryTab.tsx + โ”‚ โ”œโ”€โ”€ useLibrarySettings.ts + โ”‚ โ”œโ”€โ”€ PlexSection.tsx + โ”‚ โ”œโ”€โ”€ AudiobookshelfSection.tsx + โ”‚ โ””โ”€โ”€ index.ts + โ”œโ”€โ”€ AuthTab/ # Authentication (OIDC + Manual) + โ”‚ โ”œโ”€โ”€ AuthTab.tsx + โ”‚ โ”œโ”€โ”€ useAuthSettings.ts + โ”‚ โ”œโ”€โ”€ OIDCSection.tsx + โ”‚ โ”œโ”€โ”€ RegistrationSection.tsx + โ”‚ โ”œโ”€โ”€ PendingUsersTable.tsx + โ”‚ โ””โ”€โ”€ index.ts + โ”œโ”€โ”€ IndexersTab/ # Prowlarr/indexers + โ”‚ โ”œโ”€โ”€ IndexersTab.tsx + โ”‚ โ”œโ”€โ”€ useIndexersSettings.ts + โ”‚ โ””โ”€โ”€ index.ts + โ”œโ”€โ”€ DownloadTab/ # qBittorrent/SABnzbd + โ”‚ โ”œโ”€โ”€ DownloadTab.tsx + โ”‚ โ”œโ”€โ”€ useDownloadSettings.ts + โ”‚ โ””โ”€โ”€ index.ts + โ”œโ”€โ”€ PathsTab/ # Directory paths + โ”‚ โ”œโ”€โ”€ PathsTab.tsx + โ”‚ โ”œโ”€โ”€ usePathsSettings.ts + โ”‚ โ””โ”€โ”€ index.ts + โ”œโ”€โ”€ EbookTab/ # E-book sidecar + โ”‚ โ”œโ”€โ”€ EbookTab.tsx + โ”‚ โ”œโ”€โ”€ useEbookSettings.ts + โ”‚ โ””โ”€โ”€ index.ts + โ””โ”€โ”€ BookDateTab/ # AI recommendations + โ”œโ”€โ”€ BookDateTab.tsx + โ”œโ”€โ”€ useBookDateSettings.ts + โ””โ”€โ”€ index.ts +``` + +**Benefits:** +- Single Responsibility: Each tab manages its own state/logic +- Testability: Individual tabs can be unit tested +- Maintainability: Changes to one feature don't affect others +- Performance: Lazy loading possible (future optimization) +- Reusability: Custom hooks can be used elsewhere +- Code Quality: Follows React best practices + ## Sections 1. **Plex** - URL, token (masked), library ID, Audible region, filesystem scan trigger toggle diff --git a/documentation/testing.md b/documentation/testing.md new file mode 100644 index 0000000..9472312 --- /dev/null +++ b/documentation/testing.md @@ -0,0 +1,28 @@ +# Testing + +**Status:** โณ In Progress | Backend unit testing framework (Vitest) + +## Overview +Unit tests for backend logic with isolated mocks (Prisma, integrations, queue). + +## Key Details +- **Runner:** Vitest (`vitest.config.ts`, Node environment) +- **Setup:** `tests/setup.ts` sets `NODE_ENV=test`, `TZ=UTC`, blocks unmocked fetch +- **Helpers:** `tests/helpers/prisma.ts`, `tests/helpers/job-queue.ts` +- **GitHub Actions:** Manual workflow `.github/workflows/manual-tests.yml` runs `npm test` +- **Coverage:** `npm run test:coverage` (reports in `coverage/`) +- **Scope:** Backend unit tests only; no real network or services + +## API/Interfaces +``` +npm run test +npm run test:watch +npm run test:coverage +``` + +## Critical Issues +- API route unit tests are incomplete; add route-level mocks before enforcing coverage. + +## Related +- [backend/services/jobs.md](backend/services/jobs.md) +- [backend/services/scheduler.md](backend/services/scheduler.md) diff --git a/package-lock.json b/package-lock.json index c8f3e72..8f07910 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,9 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/bcrypt": "^5.0.2", "@types/bull": "^4.10.0", "@types/jsonwebtoken": "^9.0.6", @@ -44,14 +47,32 @@ "@types/react-dom": "^19", "@types/string-similarity": "^4.0.2", "@types/xml2js": "^0.4.14", + "@vitest/coverage-v8": "^4.0.17", "eslint": "^9", "eslint-config-next": "16.0.7", + "jsdom": "^27.4.0", "prisma": "^6.19.0", "tailwindcss": "^4", "ts-node": "^10.9.2", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^6.0.4", + "vitest": "^4.0.17" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -65,6 +86,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -277,6 +353,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -325,6 +411,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -349,6 +445,141 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz", + "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -382,6 +613,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -526,6 +1199,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", + "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1676,6 +2367,356 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2021,6 +3062,107 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@thaunknown/thirty-two": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@thaunknown/thirty-two/-/thirty-two-1.0.5.tgz", @@ -2072,6 +3214,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/bcrypt": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", @@ -2105,6 +3255,24 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2803,6 +3971,160 @@ "win32" ] }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", + "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.17", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.17", + "vitest": "4.0.17" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", + "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", + "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.17", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", + "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", + "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", + "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", + "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", + "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -3128,6 +4450,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3135,6 +4467,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3258,6 +4609,16 @@ "node": ">=12.20.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3480,6 +4841,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3774,6 +5145,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -3786,6 +5171,39 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz", @@ -3809,6 +5227,57 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3890,6 +5359,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4058,6 +5534,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -4347,6 +5831,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4405,6 +5896,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4862,6 +6395,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4881,6 +6424,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -5177,6 +6730,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5447,6 +7015,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5612,6 +7187,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", @@ -5649,6 +7244,30 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -6055,6 +7674,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6214,6 +7840,61 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -6277,6 +7958,133 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6810,6 +8618,17 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6820,6 +8639,20 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/magnet-uri": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/magnet-uri/-/magnet-uri-7.0.7.tgz", @@ -6887,6 +8720,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6941,6 +8781,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7460,6 +9310,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -7827,6 +9688,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prisma": { "version": "6.19.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", @@ -8032,6 +9931,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -8097,6 +10010,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8183,6 +10106,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8294,6 +10262,19 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -8511,6 +10492,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -8533,12 +10521,26 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8724,6 +10726,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8811,6 +10826,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", @@ -8861,6 +10883,13 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -8919,6 +10948,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8932,6 +10991,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -8995,6 +11067,27 @@ } } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -9306,6 +11399,237 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.0.4.tgz", + "integrity": "sha512-iIsEJ+ek5KqRTK17pmxtgIxXtqr3qDdE6OxrP9mVeGhVDNXRJTKN/l9oMbujTQNzMLe6XZ8qmpztfbkPu2TiFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3", + "vite": "*" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", + "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.17", + "@vitest/mocker": "4.0.17", + "@vitest/pretty-format": "4.0.17", + "@vitest/runner": "4.0.17", + "@vitest/snapshot": "4.0.17", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.17", + "@vitest/browser-preview": "4.0.17", + "@vitest/browser-webdriverio": "4.0.17", + "@vitest/ui": "4.0.17", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -9457,6 +11781,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -9482,6 +11823,38 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -9504,6 +11877,13 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index b922c3c..bbad8dd 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "build": "next build", "start": "next start", "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", @@ -41,6 +44,9 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/bcrypt": "^5.0.2", "@types/bull": "^4.10.0", "@types/jsonwebtoken": "^9.0.6", @@ -50,11 +56,15 @@ "@types/react-dom": "^19", "@types/string-similarity": "^4.0.2", "@types/xml2js": "^0.4.14", + "@vitest/coverage-v8": "^4.0.17", "eslint": "^9", "eslint-config-next": "16.0.7", + "jsdom": "^27.4.0", "prisma": "^6.19.0", "tailwindcss": "^4", "ts-node": "^10.9.2", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^6.0.4", + "vitest": "^4.0.17" } } diff --git a/src/app/admin/settings/hooks/useSettings.ts b/src/app/admin/settings/hooks/useSettings.ts new file mode 100644 index 0000000..0d41ac9 --- /dev/null +++ b/src/app/admin/settings/hooks/useSettings.ts @@ -0,0 +1,169 @@ +/** + * Component: Admin Settings - Global Settings Hook + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; +import type { Settings, Message, ValidationState, TestResult } from '../lib/types'; + +/** + * Global settings hook for managing settings state across all tabs + * Provides centralized settings fetch/update logic + */ +export function useSettings() { + const [settings, setSettings] = useState(null); + const [originalSettings, setOriginalSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [message, setMessage] = useState(null); + const [validated, setValidated] = useState({ + plex: false, + audiobookshelf: false, + oidc: false, + registration: false, + prowlarr: false, + download: false, + paths: false, + }); + const [testResults, setTestResults] = useState>({}); + + /** + * Fetch settings from API + */ + const fetchSettings = useCallback(async () => { + try { + const response = await fetchWithAuth('/api/admin/settings'); + if (response.ok) { + const data = await response.json(); + + // Convert OIDC allowed lists from JSON arrays to comma-separated strings for display + if (data.oidc) { + const parseArrayToCommaSeparated = (jsonStr: string): string => { + try { + const arr = JSON.parse(jsonStr); + return Array.isArray(arr) ? arr.join(', ') : ''; + } catch { + return ''; + } + }; + + data.oidc.allowedEmails = parseArrayToCommaSeparated(data.oidc.allowedEmails); + data.oidc.allowedUsernames = parseArrayToCommaSeparated(data.oidc.allowedUsernames); + } + + setSettings(data); + setOriginalSettings(JSON.parse(JSON.stringify(data))); // Deep copy for comparison + } else { + console.error('Failed to fetch settings:', response.status, response.statusText); + } + } catch (error) { + console.error('Failed to fetch settings:', error); + } finally { + setLoading(false); + } + }, []); + + /** + * Update settings (local state only, call saveSettings to persist) + */ + const updateSettings = useCallback((updates: Partial | ((prev: Settings) => Settings)) => { + setSettings((prev) => { + if (!prev) return prev; + if (typeof updates === 'function') { + return updates(prev); + } + return { ...prev, ...updates }; + }); + }, []); + + /** + * Reset settings to original values + */ + const resetSettings = useCallback(() => { + if (originalSettings) { + setSettings(JSON.parse(JSON.stringify(originalSettings))); + } + }, [originalSettings]); + + /** + * Check if settings have been modified + */ + const hasUnsavedChanges = useCallback(() => { + if (!settings || !originalSettings) return false; + return JSON.stringify(settings) !== JSON.stringify(originalSettings); + }, [settings, originalSettings]); + + /** + * Update validation state for a specific section + */ + const updateValidation = useCallback((section: keyof ValidationState, isValid: boolean) => { + setValidated((prev) => ({ ...prev, [section]: isValid })); + }, []); + + /** + * Update test results for a specific section + */ + const updateTestResults = useCallback((section: string, result: TestResult) => { + setTestResults((prev) => ({ ...prev, [section]: result })); + }, []); + + /** + * Show a message banner + */ + const showMessage = useCallback((msg: Message) => { + setMessage(msg); + }, []); + + /** + * Clear message banner + */ + const clearMessage = useCallback(() => { + setMessage(null); + }, []); + + /** + * Mark settings as saved (update original settings) + */ + const markAsSaved = useCallback(() => { + if (settings) { + setOriginalSettings(JSON.parse(JSON.stringify(settings))); + } + }, [settings]); + + // Fetch settings on mount + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + return { + // State + settings, + originalSettings, + loading, + saving, + testing, + message, + validated, + testResults, + + // Setters + setSettings, + setSaving, + setTesting, + + // Methods + fetchSettings, + updateSettings, + resetSettings, + hasUnsavedChanges, + updateValidation, + updateTestResults, + showMessage, + clearMessage, + markAsSaved, + }; +} diff --git a/src/app/admin/settings/lib/helpers.ts b/src/app/admin/settings/lib/helpers.ts new file mode 100644 index 0000000..913f6e8 --- /dev/null +++ b/src/app/admin/settings/lib/helpers.ts @@ -0,0 +1,206 @@ +/** + * Component: Admin Settings - Helper Functions + * Documentation: documentation/settings-pages.md + */ + +import { fetchWithAuth } from '@/lib/utils/api'; +import type { Settings, SettingsTab, SavedIndexerConfig } from './types'; +import type { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm'; + +/** + * Converts JSON array string to comma-separated string for display + */ +export const parseArrayToCommaSeparated = (jsonStr: string): string => { + try { + const arr = JSON.parse(jsonStr); + return Array.isArray(arr) ? arr.join(', ') : ''; + } catch { + return ''; + } +}; + +/** + * Converts comma-separated string to JSON array string for storage + */ +export const parseCommaSeparatedToArray = (str: string): string => { + if (!str || str.trim() === '') return '[]'; + const items = str.split(',').map(s => s.trim()).filter(s => s.length > 0); + return JSON.stringify(items); +}; + +/** + * Saves settings for a specific tab + */ +export const saveTabSettings = async ( + activeTab: SettingsTab, + settings: Settings, + configuredIndexers: SavedIndexerConfig[], + flagConfigs: IndexerFlagConfig[] +): Promise => { + switch (activeTab) { + case 'library': + // Save Audible region + await fetchWithAuth('/api/admin/settings/audible', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ region: settings.audibleRegion }), + }).then(res => { + if (!res.ok) throw new Error('Failed to save Audible region settings'); + }); + + // Save backend-specific settings + if (settings.backendMode === 'plex') { + await fetchWithAuth('/api/admin/settings/plex', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings.plex), + }).then(res => { + if (!res.ok) throw new Error('Failed to save Plex settings'); + }); + } else { + await fetchWithAuth('/api/admin/settings/audiobookshelf', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings.audiobookshelf), + }).then(res => { + if (!res.ok) throw new Error('Failed to save Audiobookshelf settings'); + }); + } + break; + + case 'auth': + // Save OIDC settings if enabled + if (settings.oidc.enabled) { + const oidcPayload = { + ...settings.oidc, + allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails), + allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames), + }; + + await fetchWithAuth('/api/admin/settings/oidc', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(oidcPayload), + }).then(res => { + if (!res.ok) throw new Error('Failed to save OIDC settings'); + }); + } + + // Save registration settings + await fetchWithAuth('/api/admin/settings/registration', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings.registration), + }).then(res => { + if (!res.ok) throw new Error('Failed to save registration settings'); + }); + break; + + case 'prowlarr': + // Save Prowlarr URL and API key + await fetchWithAuth('/api/admin/settings/prowlarr', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings.prowlarr), + }).then(res => { + if (!res.ok) throw new Error('Failed to save Prowlarr settings'); + }); + + // Save indexer configuration and flag configs + const indexersForSave = configuredIndexers.map(idx => ({ ...idx, enabled: true })); + await fetchWithAuth('/api/admin/settings/prowlarr/indexers', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ indexers: indexersForSave, flagConfigs }), + }).then(res => { + if (!res.ok) throw new Error('Failed to save indexer configuration'); + }); + break; + + case 'download': + await fetchWithAuth('/api/admin/settings/download-client', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings.downloadClient), + }).then(res => { + if (!res.ok) throw new Error('Failed to save download client settings'); + }); + break; + + case 'paths': + await fetchWithAuth('/api/admin/settings/paths', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings.paths), + }).then(res => { + if (!res.ok) throw new Error('Failed to save paths settings'); + }); + break; + + default: + throw new Error('Unknown settings tab or tab handles its own saving'); + } +}; + +/** + * Validates that authentication is properly configured in Audiobookshelf mode + */ +export const validateAuthSettings = (settings: Settings): { valid: boolean; message?: string } => { + if (settings.backendMode === 'audiobookshelf') { + if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) { + return { + valid: false, + message: 'At least one authentication method must be enabled (OIDC or Manual Registration) since no local users exist. Otherwise, you will be locked out of the system.', + }; + } + } + return { valid: true }; +}; + +/** + * Gets validation status for the current tab + */ +export const getTabValidation = ( + activeTab: SettingsTab, + settings: Settings, + validated: { + plex: boolean; + audiobookshelf: boolean; + oidc: boolean; + registration: boolean; + prowlarr: boolean; + download: boolean; + paths: boolean; + } +): boolean => { + switch (activeTab) { + case 'library': + return settings.backendMode === 'plex' ? validated.plex : validated.audiobookshelf; + case 'auth': + return validated.oidc || validated.registration; + case 'prowlarr': + return validated.prowlarr; + case 'download': + return validated.download; + case 'paths': + return validated.paths; + case 'ebook': + case 'bookdate': + return true; // These tabs handle their own saving + default: + return false; + } +}; + +/** + * Gets tab configuration based on backend mode + */ +export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [ + { id: 'library' as const, label: backendMode === 'plex' ? 'Plex' : 'Audiobookshelf', icon: '๐Ÿ“บ' }, + ...(backendMode === 'audiobookshelf' ? [{ id: 'auth' as const, label: 'Authentication', icon: '๐Ÿ”' }] : []), + { id: 'prowlarr' as const, label: 'Indexers', icon: '๐Ÿ”' }, + { id: 'download' as const, label: 'Download Client', icon: 'โฌ‡๏ธ' }, + { id: 'paths' as const, label: 'Paths', icon: '๐Ÿ“' }, + { id: 'ebook' as const, label: 'E-book Sidecar', icon: '๐Ÿ“–' }, + { id: 'bookdate' as const, label: 'BookDate', icon: '๐Ÿ“š' }, +]; diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts new file mode 100644 index 0000000..56b3e08 --- /dev/null +++ b/src/app/admin/settings/lib/types.ts @@ -0,0 +1,223 @@ +/** + * Component: Admin Settings - Shared Types + * Documentation: documentation/settings-pages.md + */ + +/** + * Main settings object structure + */ +export interface Settings { + backendMode: 'plex' | 'audiobookshelf'; + hasLocalUsers: boolean; + audibleRegion: string; + plex: PlexSettings; + audiobookshelf: AudiobookshelfSettings; + oidc: OIDCSettings; + registration: RegistrationSettings; + prowlarr: ProwlarrSettings; + downloadClient: DownloadClientSettings; + paths: PathsSettings; + ebook: EbookSettings; +} + +/** + * Plex library configuration + */ +export interface PlexSettings { + url: string; + token: string; + libraryId: string; + triggerScanAfterImport: boolean; +} + +/** + * Audiobookshelf library configuration + */ +export interface AudiobookshelfSettings { + serverUrl: string; + apiToken: string; + libraryId: string; + triggerScanAfterImport: boolean; +} + +/** + * OIDC authentication configuration + */ +export interface OIDCSettings { + enabled: boolean; + providerName: string; + issuerUrl: string; + clientId: string; + clientSecret: string; + accessControlMethod: string; + accessGroupClaim: string; + accessGroupValue: string; + allowedEmails: string; + allowedUsernames: string; + adminClaimEnabled: boolean; + adminClaimName: string; + adminClaimValue: string; +} + +/** + * Manual registration configuration + */ +export interface RegistrationSettings { + enabled: boolean; + requireAdminApproval: boolean; +} + +/** + * Prowlarr indexer configuration + */ +export interface ProwlarrSettings { + url: string; + apiKey: string; +} + +/** + * Download client (qBittorrent) configuration + */ +export interface DownloadClientSettings { + type: string; + url: string; + username: string; + password: string; + disableSSLVerify: boolean; + remotePathMappingEnabled: boolean; + remotePath: string; + localPath: string; +} + +/** + * File paths and processing configuration + */ +export interface PathsSettings { + downloadDir: string; + mediaDir: string; + metadataTaggingEnabled: boolean; + chapterMergingEnabled: boolean; +} + +/** + * E-book sidecar configuration + */ +export interface EbookSettings { + enabled: boolean; + preferredFormat: string; + baseUrl: string; + flaresolverrUrl: string; +} + +/** + * Plex library item + */ +export interface PlexLibrary { + id: string; + title: string; + type: string; +} + +/** + * Audiobookshelf library item + */ +export interface ABSLibrary { + id: string; + name: string; + type: string; + itemCount: number; +} + +/** + * Prowlarr indexer configuration + */ +export interface IndexerConfig { + id: number; + name: string; + protocol: string; + privacy: string; + enabled: boolean; + priority: number; + seedingTimeMinutes: number; + rssEnabled: boolean; + categories?: number[]; + supportsRss?: boolean; +} + +/** + * Saved indexer configuration (subset for UI) + */ +export interface SavedIndexerConfig { + id: number; + name: string; + priority: number; + seedingTimeMinutes: number; + rssEnabled: boolean; + categories: number[]; +} + +/** + * Pending user awaiting approval + */ +export interface PendingUser { + id: string; + plexUsername: string; + plexEmail: string | null; + authProvider: string | null; + createdAt: string; +} + +/** + * Validation state for all settings sections + */ +export interface ValidationState { + plex?: boolean; + audiobookshelf?: boolean; + oidc?: boolean; + registration?: boolean; + prowlarr?: boolean; + download?: boolean; + paths?: boolean; +} + +/** + * Test result for connection tests + */ +export interface TestResult { + success: boolean; + message: string; + responseTime?: number; +} + +/** + * Message/notification display + */ +export interface Message { + type: 'success' | 'error'; + text: string; +} + +/** + * BookDate AI provider configuration + */ +export interface BookDateConfig { + provider: string; + apiKey?: string; + model: string; + baseUrl?: string; + isEnabled: boolean; + isVerified: boolean; +} + +/** + * BookDate AI model option + */ +export interface BookDateModel { + id: string; + name: string; +} + +/** + * Tab identifier type + */ +export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate'; diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index f5ef370..0990d9f 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -1,132 +1,42 @@ /** - * Component: Admin Settings Page + * Component: Admin Settings Page (Refactored Shell) * Documentation: documentation/settings-pages.md + * + * This is a refactored shell component that orchestrates the modular tab components. + * Each tab has been extracted into its own component with dedicated hooks for state management. */ 'use client'; import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; import Link from 'next/link'; import { fetchWithAuth } from '@/lib/utils/api'; import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm'; -import { FlagConfigRow } from '@/components/admin/FlagConfigRow'; -import { IndexersTab } from './tabs/IndexersTab'; -interface PlexLibrary { - id: string; - title: string; - type: string; -} +// Tab Components +import { LibraryTab } from './tabs/LibraryTab/LibraryTab'; +import { AuthTab } from './tabs/AuthTab/AuthTab'; +import { IndexersTab } from './tabs/IndexersTab/IndexersTab'; +import { DownloadTab } from './tabs/DownloadTab/DownloadTab'; +import { PathsTab } from './tabs/PathsTab/PathsTab'; +import { EbookTab } from './tabs/EbookTab/EbookTab'; +import { BookDateTab } from './tabs/BookDateTab/BookDateTab'; -interface IndexerConfig { - id: number; - name: string; - protocol: string; - privacy: string; - enabled: boolean; - priority: number; - seedingTimeMinutes: number; - rssEnabled: boolean; - categories?: number[]; - supportsRss?: boolean; -} - -interface Settings { - backendMode: 'plex' | 'audiobookshelf'; - hasLocalUsers: boolean; - audibleRegion: string; - plex: { - url: string; - token: string; - libraryId: string; - triggerScanAfterImport: boolean; - }; - audiobookshelf: { - serverUrl: string; - apiToken: string; - libraryId: string; - triggerScanAfterImport: boolean; - }; - oidc: { - enabled: boolean; - providerName: string; - issuerUrl: string; - clientId: string; - clientSecret: string; - accessControlMethod: string; - accessGroupClaim: string; - accessGroupValue: string; - allowedEmails: string; - allowedUsernames: string; - adminClaimEnabled: boolean; - adminClaimName: string; - adminClaimValue: string; - }; - registration: { - enabled: boolean; - requireAdminApproval: boolean; - }; - prowlarr: { - url: string; - apiKey: string; - }; - downloadClient: { - type: string; - url: string; - username: string; - password: string; - disableSSLVerify: boolean; - remotePathMappingEnabled: boolean; - remotePath: string; - localPath: string; - }; - paths: { - downloadDir: string; - mediaDir: string; - metadataTaggingEnabled: boolean; - chapterMergingEnabled: boolean; - }; - ebook: { - enabled: boolean; - preferredFormat: string; - baseUrl: string; - flaresolverrUrl: string; - }; -} - -interface PendingUser { - id: string; - plexUsername: string; - plexEmail: string | null; - authProvider: string | null; - createdAt: string; -} - -interface ABSLibrary { - id: string; - name: string; - type: string; - itemCount: number; -} +// Types and Helpers +import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types'; +import { parseArrayToCommaSeparated, saveTabSettings, validateAuthSettings, getTabValidation, getTabs } from './lib/helpers'; export default function AdminSettings() { + // Core state const [settings, setSettings] = useState(null); - const [originalSettings, setOriginalSettings] = useState(null); // Track original values - const [plexLibraries, setPlexLibraries] = useState([]); - const [absLibraries, setAbsLibraries] = useState([]); - const [indexers, setIndexers] = useState([]); - const [configuredIndexers, setConfiguredIndexers] = useState>([]); - const [flagConfigs, setFlagConfigs] = useState([]); - const [pendingUsers, setPendingUsers] = useState([]); - const [isLocalAdmin, setIsLocalAdmin] = useState(false); + const [originalSettings, setOriginalSettings] = useState(null); const [loading, setLoading] = useState(true); - const [loadingLibraries, setLoadingLibraries] = useState(false); - const [loadingIndexers, setLoadingIndexers] = useState(false); - const [loadingPendingUsers, setLoadingPendingUsers] = useState(false); const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); + const [message, setMessage] = useState(null); + const [activeTab, setActiveTab] = useState('library'); + + // Validation state (tracks if each tab's settings are valid) const [validated, setValidated] = useState({ plex: false, audiobookshelf: false, @@ -136,79 +46,19 @@ export default function AdminSettings() { download: false, paths: false, }); - const [testResults, setTestResults] = useState>({}); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>( - null - ); - const [activeTab, setActiveTab] = useState<'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate'>('library'); - // BookDate configuration state - const [bookdateProvider, setBookdateProvider] = useState('openai'); - const [bookdateApiKey, setBookdateApiKey] = useState(''); - const [bookdateModel, setBookdateModel] = useState(''); - const [bookdateBaseUrl, setBookdateBaseUrl] = useState(''); - const [bookdateEnabled, setBookdateEnabled] = useState(true); - const [bookdateConfigured, setBookdateConfigured] = useState(false); - const [bookdateModels, setBookdateModels] = useState<{ id: string; name: string }[]>([]); - const [testingBookdate, setTestingBookdate] = useState(false); - const [clearingBookdateSwipes, setClearingBookdateSwipes] = useState(false); - - // FlareSolverr testing state - const [testingFlaresolverr, setTestingFlaresolverr] = useState(false); - const [flaresolverrTestResult, setFlaresolverrTestResult] = useState<{ - success: boolean; - message: string; - responseTime?: number; - } | null>(null); + // Indexer-specific state (used by IndexersTab) + const [configuredIndexers, setConfiguredIndexers] = useState([]); + const [flagConfigs, setFlagConfigs] = useState([]); + // Initial data fetch useEffect(() => { fetchSettings(); - fetchCurrentUser(); }, []); - const fetchCurrentUser = async () => { - try { - const response = await fetchWithAuth('/api/auth/me'); - if (response.ok) { - const data = await response.json(); - setIsLocalAdmin(data.user?.isLocalAdmin || false); - } - } catch (error) { - console.error('Failed to fetch current user:', error); - } - }; - - // Fetch libraries/indexers when tabs become active or when page first loads - useEffect(() => { - if (!settings) return; - - if (activeTab === 'library' && settings.backendMode === 'plex' && settings.plex.url && settings.plex.token) { - fetchPlexLibraries(); - } else if (activeTab === 'library' && settings.backendMode === 'audiobookshelf' && settings.audiobookshelf.serverUrl && settings.audiobookshelf.apiToken) { - fetchABSLibraries(); - } - }, [activeTab, settings?.plex.url, settings?.plex.token, settings?.audiobookshelf.serverUrl, settings?.audiobookshelf.apiToken, settings?.backendMode]); - - useEffect(() => { - if (!settings) return; - - if (activeTab === 'prowlarr' && settings.prowlarr.url && settings.prowlarr.apiKey) { - fetchIndexers(); - } - }, [activeTab, settings?.prowlarr.url, settings?.prowlarr.apiKey]); - - useEffect(() => { - if (activeTab === 'bookdate') { - fetchBookdateConfig(); - } - }, [activeTab]); - - useEffect(() => { - if (activeTab === 'auth' && settings?.registration.requireAdminApproval) { - fetchPendingUsers(); - } - }, [activeTab, settings?.registration.requireAdminApproval]); - + /** + * Fetches all settings from the API + */ const fetchSettings = async () => { try { const response = await fetchWithAuth('/api/admin/settings'); @@ -217,23 +67,12 @@ export default function AdminSettings() { // Convert OIDC allowed lists from JSON arrays to comma-separated strings for display if (data.oidc) { - const parseArrayToCommaSeparated = (jsonStr: string): string => { - try { - const arr = JSON.parse(jsonStr); - return Array.isArray(arr) ? arr.join(', ') : ''; - } catch { - return ''; - } - }; - data.oidc.allowedEmails = parseArrayToCommaSeparated(data.oidc.allowedEmails); data.oidc.allowedUsernames = parseArrayToCommaSeparated(data.oidc.allowedUsernames); } setSettings(data); - setOriginalSettings(JSON.parse(JSON.stringify(data))); // Deep copy for comparison - } else { - console.error('Failed to fetch settings:', response.status, response.statusText); + setOriginalSettings(JSON.parse(JSON.stringify(data))); } } catch (error) { console.error('Failed to fetch settings:', error); @@ -242,79 +81,17 @@ export default function AdminSettings() { } }; - const fetchPlexLibraries = async (force = false) => { - if (!force && plexLibraries.length > 0) return; // Already loaded - - setLoadingLibraries(true); - try { - const response = await fetchWithAuth('/api/admin/settings/plex/libraries'); - if (response.ok) { - const data = await response.json(); - setPlexLibraries(data.libraries || []); - } else { - const data = await response.json(); - console.error('Failed to fetch Plex libraries:', data); - setMessage({ type: 'error', text: data.message || 'Failed to load Plex libraries. Check your Plex URL and token.' }); - } - } catch (error) { - console.error('Failed to fetch Plex libraries:', error); - setMessage({ type: 'error', text: 'Failed to load Plex libraries. Check your Plex URL and token.' }); - } finally { - setLoadingLibraries(false); - } - }; - - const fetchABSLibraries = async (force = false) => { - if (!force && absLibraries.length > 0) return; // Already loaded - - setLoadingLibraries(true); - try { - const response = await fetchWithAuth('/api/admin/settings/audiobookshelf/libraries'); - if (response.ok) { - const data = await response.json(); - setAbsLibraries(data.libraries || []); - } else { - const data = await response.json(); - console.error('Failed to fetch ABS libraries:', data); - setMessage({ type: 'error', text: data.message || 'Failed to load Audiobookshelf libraries. Check your server URL and API token.' }); - } - } catch (error) { - console.error('Failed to fetch ABS libraries:', error); - setMessage({ type: 'error', text: 'Failed to load Audiobookshelf libraries. Check your server URL and API token.' }); - } finally { - setLoadingLibraries(false); - } - }; - - const fetchPendingUsers = async () => { - setLoadingPendingUsers(true); - try { - const response = await fetchWithAuth('/api/admin/users/pending'); - if (response.ok) { - const data = await response.json(); - setPendingUsers(data.users || []); - } else { - console.error('Failed to fetch pending users:', response.status); - } - } catch (error) { - console.error('Failed to fetch pending users:', error); - } finally { - setLoadingPendingUsers(false); - } - }; - + /** + * Fetches indexers from Prowlarr (used by IndexersTab) + */ const fetchIndexers = async (force = false) => { - if (!force && indexers.length > 0) return; // Already loaded - - setLoadingIndexers(true); try { const response = await fetchWithAuth('/api/admin/settings/prowlarr/indexers'); if (response.ok) { const data = await response.json(); - setIndexers(data.indexers || []); setFlagConfigs(data.flagConfigs || []); - // Extract configured indexers (enabled ones) for the new IndexerManagement component + // Extract configured indexers (enabled ones) const configured = (data.indexers || []) .filter((idx: IndexerConfig) => idx.enabled) .map((idx: IndexerConfig) => ({ @@ -323,12 +100,11 @@ export default function AdminSettings() { priority: idx.priority, seedingTimeMinutes: idx.seedingTimeMinutes, rssEnabled: idx.rssEnabled, - categories: idx.categories || [3030], // Include categories, default to audiobooks + categories: idx.categories || [3030], })); setConfiguredIndexers(configured); } else { console.error('Failed to fetch indexers:', response.status); - // Don't show error on initial load, only if user explicitly tries to load if (force) { setMessage({ type: 'error', text: 'Failed to load indexers. Check your Prowlarr settings.' }); } @@ -338,670 +114,31 @@ export default function AdminSettings() { if (force) { setMessage({ type: 'error', text: 'Failed to load Prowlarr indexers. Check your Prowlarr URL and API key.' }); } - } finally { - setLoadingIndexers(false); - } - }; - - const fetchBookdateConfig = async () => { - try { - const response = await fetchWithAuth('/api/bookdate/config'); - const data = await response.json(); - - if (data.config) { - setBookdateProvider(data.config.provider || 'openai'); - setBookdateModel(data.config.model || ''); - setBookdateBaseUrl(data.config.baseUrl || ''); - setBookdateEnabled(data.config.isEnabled !== false); // Default to true - setBookdateConfigured(data.config.isVerified || false); - } - } catch (error) { - console.error('Failed to load BookDate config:', error); - } - }; - - const handleTestBookdateConnection = async () => { - const hasApiKey = bookdateApiKey.trim().length > 0; - - // Validation - if (bookdateProvider === 'custom') { - if (!bookdateBaseUrl.trim()) { - setMessage({ type: 'error', text: 'Please enter a base URL for custom provider' }); - return; - } - } else { - // Allow testing with saved API key if already configured - if (!hasApiKey && !bookdateConfigured) { - setMessage({ type: 'error', text: 'Please enter an API key' }); - return; - } - } - - setTestingBookdate(true); - setMessage(null); - - try { - const payload: any = { - provider: bookdateProvider, - }; - - // Include API key if user entered a new one, otherwise use saved key - if (hasApiKey) { - payload.apiKey = bookdateApiKey; - } else if (bookdateProvider !== 'custom') { - payload.useSavedKey = true; - } - - // Include baseUrl for custom provider - if (bookdateProvider === 'custom') { - payload.baseUrl = bookdateBaseUrl; - } - - const response = await fetchWithAuth('/api/bookdate/test-connection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Connection test failed'); - } - - setBookdateModels(data.models || []); - setMessage({ type: 'success', text: 'Connection successful! Please select a model.' }); - - // Auto-select first model if none selected - if (!bookdateModel && data.models?.length > 0) { - setBookdateModel(data.models[0].id); - } - } catch (error) { - setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Connection test failed' }); - } finally { - setTestingBookdate(false); - } - }; - - const handleSaveBookdateConfig = async () => { - // Validate: model is required - if (!bookdateModel) { - setMessage({ type: 'error', text: 'Please select a model' }); - return; - } - - // Validate: baseUrl required for custom provider - if (bookdateProvider === 'custom') { - if (!bookdateBaseUrl.trim()) { - setMessage({ type: 'error', text: 'Please enter a base URL for custom provider' }); - return; - } - } else { - // Only require API key if not already configured OR if user entered one - const hasApiKey = bookdateApiKey.trim().length > 0; - if (!bookdateConfigured && !hasApiKey) { - setMessage({ type: 'error', text: 'Please enter an API key for initial setup' }); - return; - } - } - - setSaving(true); - setMessage(null); - - try { - const hasApiKey = bookdateApiKey.trim().length > 0; - const payload: any = { - provider: bookdateProvider, - model: bookdateModel, - isEnabled: bookdateEnabled, - }; - - // Only include API key if user entered a new one - if (hasApiKey) { - payload.apiKey = bookdateApiKey; - } - - // Include baseUrl for custom provider - if (bookdateProvider === 'custom') { - payload.baseUrl = bookdateBaseUrl; - } - - const response = await fetchWithAuth('/api/bookdate/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to save configuration'); - } - - setMessage({ type: 'success', text: 'BookDate configuration saved successfully!' }); - setBookdateConfigured(true); - setBookdateApiKey(''); // Clear API key from UI after save - } catch (error) { - setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to save configuration' }); - } finally { - setSaving(false); - } - }; - - const handleClearBookdateSwipes = async () => { - if (!confirm('This will clear all swipe history. Continue?')) { - return; - } - - setClearingBookdateSwipes(true); - setMessage(null); - - try { - const response = await fetchWithAuth('/api/bookdate/swipes', { - method: 'DELETE', - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Failed to clear swipe history'); - } - - setMessage({ type: 'success', text: 'Swipe history cleared successfully!' }); - } catch (error) { - setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to clear swipe history' }); - } finally { - setClearingBookdateSwipes(false); - } - }; - - const handleSaveEbookSettings = async () => { - if (!settings) return; - - setSaving(true); - setMessage(null); - - try { - const response = await fetchWithAuth('/api/admin/settings/ebook', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - enabled: settings.ebook?.enabled || false, - format: settings.ebook?.preferredFormat || 'epub', - baseUrl: settings.ebook?.baseUrl || 'https://annas-archive.li', - flaresolverrUrl: settings.ebook?.flaresolverrUrl || '', - }), - }); - - if (!response.ok) { - throw new Error('Failed to save e-book settings'); - } - - setMessage({ type: 'success', text: 'E-book sidecar settings saved successfully!' }); - // Update original settings to reflect the saved state - setOriginalSettings(JSON.parse(JSON.stringify(settings))); - setTimeout(() => setMessage(null), 3000); - } catch (error) { - setMessage({ - type: 'error', - text: error instanceof Error ? error.message : 'Failed to save e-book settings', - }); - } finally { - setSaving(false); - } - }; - - const testFlaresolverrConnection = async () => { - if (!settings?.ebook?.flaresolverrUrl) { - setFlaresolverrTestResult({ - success: false, - message: 'Please enter a FlareSolverr URL first', - }); - return; - } - - setTestingFlaresolverr(true); - setFlaresolverrTestResult(null); - - try { - const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: settings.ebook.flaresolverrUrl }), - }); - - const result = await response.json(); - setFlaresolverrTestResult(result); - } catch (error) { - setFlaresolverrTestResult({ - success: false, - message: error instanceof Error ? error.message : 'Test failed', - }); - } finally { - setTestingFlaresolverr(false); - } - }; - - const testPlexConnection = async () => { - if (!settings) return; - - setTesting(true); - setMessage(null); - - try { - const response = await fetchWithAuth('/api/admin/settings/test-plex', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: settings.plex.url, - token: settings.plex.token, - }), - }); - - const data = await response.json(); - - if (data.success) { - setValidated({ ...validated, plex: true }); - setTestResults({ ...testResults, plex: { success: true, message: `Connected to ${data.serverName}` } }); - setMessage({ type: 'success', text: `Connected to ${data.serverName}. You can now save.` }); - // Update libraries - if (data.libraries) { - setPlexLibraries(data.libraries); - } - } else { - setValidated({ ...validated, plex: false }); - setTestResults({ ...testResults, plex: { success: false, message: data.error || 'Connection failed' } }); - setMessage({ type: 'error', text: data.error || 'Failed to connect to Plex' }); - } - } catch (error) { - setValidated({ ...validated, plex: false }); - const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; - setTestResults({ ...testResults, plex: { success: false, message: errorMsg } }); - setMessage({ type: 'error', text: errorMsg }); - } finally { - setTesting(false); - } - }; - - const testABSConnection = async () => { - if (!settings) return; - - setTesting(true); - setMessage(null); - - try { - const response = await fetchWithAuth('/api/setup/test-abs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - serverUrl: settings.audiobookshelf.serverUrl, - apiToken: settings.audiobookshelf.apiToken, - }), - }); - - const data = await response.json(); - - if (data.success) { - setValidated({ ...validated, audiobookshelf: true }); - setTestResults({ ...testResults, audiobookshelf: { success: true, message: `Connected to Audiobookshelf` } }); - setMessage({ type: 'success', text: 'Connected to Audiobookshelf. You can now save.' }); - // Update libraries - if (data.libraries) { - setAbsLibraries(data.libraries); - } - } else { - setValidated({ ...validated, audiobookshelf: false }); - setTestResults({ ...testResults, audiobookshelf: { success: false, message: data.error || 'Connection failed' } }); - setMessage({ type: 'error', text: data.error || 'Failed to connect to Audiobookshelf' }); - } - } catch (error) { - setValidated({ ...validated, audiobookshelf: false }); - const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; - setTestResults({ ...testResults, audiobookshelf: { success: false, message: errorMsg } }); - setMessage({ type: 'error', text: errorMsg }); - } finally { - setTesting(false); - } - }; - - const testOIDCConnection = async () => { - if (!settings) return; - - setTesting(true); - setMessage(null); - - try { - const response = await fetchWithAuth('/api/setup/test-oidc', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - issuerUrl: settings.oidc.issuerUrl, - clientId: settings.oidc.clientId, - clientSecret: settings.oidc.clientSecret, - }), - }); - - const data = await response.json(); - - if (data.success) { - setValidated({ ...validated, oidc: true }); - setTestResults({ ...testResults, oidc: { success: true, message: 'OIDC configuration is valid' } }); - setMessage({ type: 'success', text: 'OIDC configuration is valid. You can now save.' }); - } else { - setValidated({ ...validated, oidc: false }); - setTestResults({ ...testResults, oidc: { success: false, message: data.error || 'Connection failed' } }); - setMessage({ type: 'error', text: data.error || 'Failed to validate OIDC configuration' }); - } - } catch (error) { - setValidated({ ...validated, oidc: false }); - const errorMsg = error instanceof Error ? error.message : 'Failed to test OIDC connection'; - setTestResults({ ...testResults, oidc: { success: false, message: errorMsg } }); - setMessage({ type: 'error', text: errorMsg }); - } finally { - setTesting(false); - } - }; - - const approveUser = async (userId: string, approve: boolean) => { - try { - const response = await fetchWithAuth(`/api/admin/users/${userId}/approve`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ approve }), - }); - - const data = await response.json(); - - if (data.success) { - setMessage({ type: 'success', text: data.message }); - // Refresh pending users list - await fetchPendingUsers(); - } else { - setMessage({ type: 'error', text: data.error || 'Failed to process user approval' }); - } - } catch (error) { - setMessage({ type: 'error', text: error instanceof Error ? error.message : 'Failed to process user approval' }); - } - }; - - const testProwlarrConnection = async () => { - if (!settings) return; - - setTesting(true); - setMessage(null); - - try { - const response = await fetchWithAuth('/api/admin/settings/test-prowlarr', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: settings.prowlarr.url, - apiKey: settings.prowlarr.apiKey, - }), - }); - - const data = await response.json(); - - if (data.success) { - setValidated({ ...validated, prowlarr: true }); - setTestResults({ ...testResults, prowlarr: { success: true, message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers` } }); - setMessage({ type: 'success', text: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers. You can now save.` }); - // Refresh indexers from database (merges saved config with available indexers) - await fetchIndexers(true); - } else { - setValidated({ ...validated, prowlarr: false }); - setTestResults({ ...testResults, prowlarr: { success: false, message: data.error || 'Connection failed' } }); - setMessage({ type: 'error', text: data.error || 'Failed to connect to Prowlarr' }); - } - } catch (error) { - setValidated({ ...validated, prowlarr: false }); - const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; - setTestResults({ ...testResults, prowlarr: { success: false, message: errorMsg } }); - setMessage({ type: 'error', text: errorMsg }); - } finally { - setTesting(false); - } - }; - - const testDownloadClientConnection = async () => { - if (!settings) return; - - setTesting(true); - setMessage(null); - - try { - const response = await fetchWithAuth('/api/admin/settings/test-download-client', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: settings.downloadClient.type, - url: settings.downloadClient.url, - username: settings.downloadClient.username, - password: settings.downloadClient.password, - disableSSLVerify: settings.downloadClient.disableSSLVerify, - remotePathMappingEnabled: settings.downloadClient.remotePathMappingEnabled, - remotePath: settings.downloadClient.remotePath, - localPath: settings.downloadClient.localPath, - }), - }); - - const data = await response.json(); - - if (data.success) { - setValidated({ ...validated, download: true }); - setTestResults({ ...testResults, download: { success: true, message: `Connected to ${settings.downloadClient.type} (${data.version || 'version unknown'})` } }); - setMessage({ type: 'success', text: `Connected to ${settings.downloadClient.type}. You can now save.` }); - } else { - setValidated({ ...validated, download: false }); - setTestResults({ ...testResults, download: { success: false, message: data.error || 'Connection failed' } }); - setMessage({ type: 'error', text: data.error || 'Failed to connect to download client' }); - } - } catch (error) { - setValidated({ ...validated, download: false }); - const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; - setTestResults({ ...testResults, download: { success: false, message: errorMsg } }); - setMessage({ type: 'error', text: errorMsg }); - } finally { - setTesting(false); - } - }; - - const testPaths = async () => { - if (!settings) return; - - setTesting(true); - setMessage(null); - - try { - const response = await fetch('/api/setup/test-paths', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - downloadDir: settings.paths.downloadDir, - mediaDir: settings.paths.mediaDir, - }), - }); - - const data = await response.json(); - - if (data.success) { - setValidated({ ...validated, paths: true }); - setTestResults({ ...testResults, paths: { success: true, message: 'All paths are valid and writable' } }); - setMessage({ type: 'success', text: 'All paths are valid and writable. You can now save.' }); - } else { - setValidated({ ...validated, paths: false }); - setTestResults({ ...testResults, paths: { success: false, message: data.error || 'Path validation failed' } }); - setMessage({ type: 'error', text: data.error || 'Failed to validate paths' }); - } - } catch (error) { - setValidated({ ...validated, paths: false }); - const errorMsg = error instanceof Error ? error.message : 'Failed to test paths'; - setTestResults({ ...testResults, paths: { success: false, message: errorMsg } }); - setMessage({ type: 'error', text: errorMsg }); - } finally { - setTesting(false); } }; + /** + * Saves settings for the currently active tab + */ const saveSettings = async () => { if (!settings) return; + // Validate auth settings before saving + if (activeTab === 'auth') { + const validation = validateAuthSettings(settings); + if (!validation.valid) { + setMessage({ type: 'error', text: validation.message! }); + return; + } + } + setSaving(true); setMessage(null); try { - // Save settings based on active tab - switch (activeTab) { - case 'library': - // Save Audible region (common to both backends) - const audibleResponse = await fetchWithAuth('/api/admin/settings/audible', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ region: settings.audibleRegion }), - }); - - if (!audibleResponse.ok) { - throw new Error('Failed to save Audible region settings'); - } - - // Save backend-specific settings - if (settings.backendMode === 'plex') { - const plexResponse = await fetchWithAuth('/api/admin/settings/plex', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings.plex), - }); - - if (!plexResponse.ok) { - throw new Error('Failed to save Plex settings'); - } - } else { - const absResponse = await fetchWithAuth('/api/admin/settings/audiobookshelf', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings.audiobookshelf), - }); - - if (!absResponse.ok) { - throw new Error('Failed to save Audiobookshelf settings'); - } - } - break; - - case 'auth': - // Validate: In Audiobookshelf mode, at least one auth method must be enabled OR local users must exist - if (settings.backendMode === 'audiobookshelf') { - if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) { - setMessage({ - type: 'error', - text: 'At least one authentication method must be enabled (OIDC or Manual Registration) since no local users exist. Otherwise, you will be locked out of the system.', - }); - setSaving(false); - return; - } - } - - // Save OIDC settings if OIDC is enabled - if (settings.oidc.enabled) { - // Helper function to parse comma-separated strings into JSON arrays - const parseCommaSeparatedToArray = (str: string): string => { - if (!str || str.trim() === '') return '[]'; - const items = str.split(',').map(s => s.trim()).filter(s => s.length > 0); - return JSON.stringify(items); - }; - - const oidcPayload = { - ...settings.oidc, - allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails), - allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames), - }; - - const oidcResponse = await fetchWithAuth('/api/admin/settings/oidc', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(oidcPayload), - }); - - if (!oidcResponse.ok) { - throw new Error('Failed to save OIDC settings'); - } - } - - // Save registration settings - const registrationResponse = await fetchWithAuth('/api/admin/settings/registration', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings.registration), - }); - - if (!registrationResponse.ok) { - throw new Error('Failed to save registration settings'); - } - break; - - case 'prowlarr': - // Save Prowlarr URL and API key - const prowlarrResponse = await fetchWithAuth('/api/admin/settings/prowlarr', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings.prowlarr), - }); - - if (!prowlarrResponse.ok) { - throw new Error('Failed to save Prowlarr settings'); - } - - // Save indexer configuration and flag configs - // Convert configured indexers to the format expected by the API (with enabled: true) - const indexersForSave = configuredIndexers.map((idx) => ({ - ...idx, - enabled: true, - })); - - const indexersResponse = await fetchWithAuth('/api/admin/settings/prowlarr/indexers', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ indexers: indexersForSave, flagConfigs }), - }); - - if (!indexersResponse.ok) { - throw new Error('Failed to save indexer configuration'); - } - break; - - case 'download': - const downloadResponse = await fetchWithAuth('/api/admin/settings/download-client', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings.downloadClient), - }); - - if (!downloadResponse.ok) { - throw new Error('Failed to save download client settings'); - } - break; - - case 'paths': - const pathsResponse = await fetchWithAuth('/api/admin/settings/paths', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings.paths), - }); - - if (!pathsResponse.ok) { - throw new Error('Failed to save paths settings'); - } - break; - - default: - throw new Error('Unknown settings tab'); - } - + await saveTabSettings(activeTab, settings, configuredIndexers, flagConfigs); setMessage({ type: 'success', text: 'Settings saved successfully!' }); - // Update original settings to reflect the saved state - if (settings) { - setOriginalSettings(JSON.parse(JSON.stringify(settings))); - } + setOriginalSettings(JSON.parse(JSON.stringify(settings))); setTimeout(() => setMessage(null), 3000); } catch (error) { setMessage({ @@ -1013,6 +150,7 @@ export default function AdminSettings() { } }; + // Loading state if (loading || !settings) { return (
@@ -1021,15 +159,10 @@ export default function AdminSettings() { ); } - const tabs = [ - { id: 'library', label: settings?.backendMode === 'plex' ? 'Plex' : 'Audiobookshelf', icon: '๐Ÿ“บ' }, - ...(settings?.backendMode === 'audiobookshelf' ? [{ id: 'auth', label: 'Authentication', icon: '๐Ÿ”' }] : []), - { id: 'prowlarr', label: 'Indexers', icon: '๐Ÿ”' }, - { id: 'download', label: 'Download Client', icon: 'โฌ‡๏ธ' }, - { id: 'paths', label: 'Paths', icon: '๐Ÿ“' }, - { id: 'ebook', label: 'E-book Sidecar', icon: '๐Ÿ“–' }, - { id: 'bookdate', label: 'BookDate', icon: '๐Ÿ“š' }, - ]; + // Dynamic tabs, validation, and change detection + const tabs = getTabs(settings.backendMode); + const currentTabValidation = getTabValidation(activeTab, settings, validated); + const hasUnsavedChanges = JSON.stringify(settings) !== JSON.stringify(originalSettings); return (
@@ -1044,1924 +177,145 @@ export default function AdminSettings() {

Settings

-

- Configure external services and system preferences -

- {/* Backend Mode Display */} -
-
-
- - - -
-
-

Backend Mode: {settings?.backendMode === 'plex' ? 'Plex' : 'Audiobookshelf'}

-

- โš ๏ธ Backend mode cannot be changed after setup. To switch backends, you must reset the instance and run the setup wizard again. -

-
-
+ {/* Tab Navigation */} +
+
- {/* Message Banner */} + {/* Message Display */} {message && (
-

- {message.text} -

+ {message.text}
)} -
- {/* Tabs */} -
- -
- - {/* Content */} -
- {/* Library Tab - Conditional (Plex or Audiobookshelf) */} - {activeTab === 'library' && settings?.backendMode === 'plex' && ( -
-
-

- Plex Media Server -

-

- Configure your Plex server connection and audiobook library. -

-
- -
- - { - setSettings({ - ...settings, - plex: { ...settings.plex, url: e.target.value }, - }); - setValidated({ ...validated, plex: false }); - }} - placeholder="http://localhost:32400" - /> -
- -
- - { - setSettings({ - ...settings, - plex: { ...settings.plex, token: e.target.value }, - }); - setValidated({ ...validated, plex: false }); - }} - placeholder="Enter your Plex token" - /> -

- Find your token in Plex settings โ†’ Network โ†’ Show Advanced -

-
- -
- - {loadingLibraries ? ( -
-
- Loading libraries... -
- ) : plexLibraries.length > 0 ? ( - - ) : ( -
- Test your connection to load libraries. -
- )} -
- -
- -
- - {/* Audible Region Selection */} -
- - -

- Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) - configuration in Plex. This ensures accurate book matching and metadata. -

-
- -
- - {testResults.plex && ( -
- {testResults.plex.message} -
- )} -
-
- )} - - {/* Audiobookshelf Tab */} - {activeTab === 'library' && settings?.backendMode === 'audiobookshelf' && ( -
-
-

- Audiobookshelf Server -

-

- Configure your Audiobookshelf server connection and audiobook library. -

-
- -
- - { - setSettings({ - ...settings, - audiobookshelf: { ...settings.audiobookshelf, serverUrl: e.target.value }, - }); - setValidated({ ...validated, audiobookshelf: false }); - }} - placeholder="http://localhost:13378" - /> -
- -
- - { - setSettings({ - ...settings, - audiobookshelf: { ...settings.audiobookshelf, apiToken: e.target.value }, - }); - setValidated({ ...validated, audiobookshelf: false }); - }} - placeholder="Enter your Audiobookshelf API token" - /> -

- Generate in Audiobookshelf: Settings โ†’ API Keys โ†’ Add API Key -

-
- -
- - {loadingLibraries ? ( -
-
- Loading libraries... -
- ) : absLibraries.length > 0 ? ( - - ) : ( -
- Test your connection to load libraries. -
- )} -
- -
- -
- - {/* Audible Region Selection */} -
- - -

- Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) - configuration in Audiobookshelf. This ensures accurate book matching and metadata. -

-
- -
- - {testResults.audiobookshelf && ( -
- {testResults.audiobookshelf.message} -
- )} -
-
- )} - - {/* Prowlarr/Indexers Tab */} - {activeTab === 'prowlarr' && ( - - )} - - {/* Download Client Tab */} - {activeTab === 'download' && ( -
-
-

- Download Client -

-

- Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads. -

-
- -
- - -
- -
- - { - setSettings({ - ...settings, - downloadClient: { ...settings.downloadClient, url: e.target.value }, - }); - setValidated({ ...validated, download: false }); - }} - placeholder="http://localhost:8080" - /> -
- - {/* qBittorrent: Username + Password */} - {settings.downloadClient.type === 'qbittorrent' && ( - <> -
- - { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - username: e.target.value, - }, - }); - setValidated({ ...validated, download: false }); - }} - placeholder="admin" - /> -
- -
- - { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - password: e.target.value, - }, - }); - setValidated({ ...validated, download: false }); - }} - placeholder="Enter password" - /> -
- - )} - - {/* SABnzbd: API Key only */} - {settings.downloadClient.type === 'sabnzbd' && ( -
- - { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - password: e.target.value, - }, - }); - setValidated({ ...validated, download: false }); - }} - placeholder="Enter SABnzbd API key" - /> -

- Find this in SABnzbd under Config โ†’ General โ†’ API Key -

-
- )} - - {/* SSL Verification Toggle */} - {settings.downloadClient.url.startsWith('https') && ( -
-
- { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - disableSSLVerify: e.target.checked, - }, - }); - setValidated({ ...validated, download: false }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" - /> -
- -

- Enable this if you're using a self-signed certificate or getting SSL errors. - โš ๏ธ Only use on trusted private networks. -

-
-
-
- )} - - {/* Remote Path Mapping */} -
-
- { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - remotePathMappingEnabled: e.target.checked, - }, - }); - setValidated({ ...validated, download: false }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" - /> -
- -

- Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers) -

-

- Example: Remote /remote/mnt/d/done โ†’ Local /downloads -

- - {/* Warning for existing downloads */} - {settings.downloadClient.remotePathMappingEnabled && ( -
-

- โš ๏ธ Note: Path mapping only affects new downloads. In-progress downloads will continue using their original paths. -

-
- )} - - {/* Conditional Fields */} - {settings.downloadClient.remotePathMappingEnabled && ( -
-
- - { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - remotePath: e.target.value, - }, - }); - setValidated({ ...validated, download: false }); - }} - /> -

- The path prefix as reported by qBittorrent -

-
- -
- - { - setSettings({ - ...settings, - downloadClient: { - ...settings.downloadClient, - localPath: e.target.value, - }, - }); - setValidated({ ...validated, download: false }); - }} - /> -

- The actual path where files are accessible -

-
-
- )} -
-
-
- -
- - {testResults.download && ( -
- {testResults.download.message} -
- )} -
-
- )} - - {/* Paths Tab */} - {activeTab === 'paths' && ( -
-
-

- Directory Paths -

-

- Configure download and media directory paths. -

-
- -
- - { - setSettings({ - ...settings, - paths: { ...settings.paths, downloadDir: e.target.value }, - }); - setValidated({ ...validated, paths: false }); - }} - placeholder="/downloads" - className="font-mono" - /> -

- Temporary location for torrent downloads (kept for seeding) -

-
- -
- - { - setSettings({ - ...settings, - paths: { ...settings.paths, mediaDir: e.target.value }, - }); - setValidated({ ...validated, paths: false }); - }} - placeholder="/media/audiobooks" - className="font-mono" - /> -

- Final location for organized audiobook library (Plex scans this directory) -

-
- - {/* Metadata Tagging Toggle */} -
-
- { - setSettings({ - ...settings, - paths: { ...settings.paths, metadataTaggingEnabled: e.target.checked }, - }); - setValidated({ ...validated, paths: false }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- Automatically write correct title, author, and narrator metadata to m4b and mp3 files - during file organization. This significantly improves Plex matching accuracy for audiobooks - with missing or incorrect metadata. -

-
-
-
- - {/* Chapter Merging Toggle */} -
-
- { - setSettings({ - ...settings, - paths: { ...settings.paths, chapterMergingEnabled: e.target.checked }, - }); - setValidated({ ...validated, paths: false }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter - markers. Improves playback experience and library organization. -

-
-
-
- -
- - {testResults.paths && ( -
- {testResults.paths.message} -
- )} -
-
- )} - - {/* E-book Sidecar Tab */} - {activeTab === 'ebook' && ( -
-
-

- E-book Sidecar -

-

- Automatically download e-books from Anna's Archive to accompany your audiobooks. - E-books are placed in the same folder as the audiobook files. -

-
- - {/* Enable Toggle */} -
-
- { - setSettings({ - ...settings, - ebook: { ...settings.ebook, enabled: e.target.checked }, - }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- When enabled, the system will search for e-books matching your audiobook's ASIN - and download them to the same folder. -

-
-
-
- - {/* Format Selection */} - {settings.ebook?.enabled && ( -
- - -

- EPUB is recommended for most e-readers. "Any format" will download the first available format. -

-
- )} - - {/* Base URL (Advanced) */} - {settings.ebook?.enabled && ( -
- - { - setSettings({ - ...settings, - ebook: { ...settings.ebook, baseUrl: e.target.value }, - }); - }} - placeholder="https://annas-archive.li" - className="font-mono" - /> -

- Change this if the primary Anna's Archive mirror is unavailable. -

-
- )} - - {/* FlareSolverr (Optional - for Cloudflare bypass) */} - {settings.ebook?.enabled && ( -
-
- -
- { - setSettings({ - ...settings, - ebook: { ...settings.ebook, flaresolverrUrl: e.target.value }, - }); - setFlaresolverrTestResult(null); - }} - placeholder="http://localhost:8191" - className="font-mono flex-1" - /> - -
-

- FlareSolverr helps bypass Cloudflare protection on Anna's Archive. - Leave empty if not needed. -

- {flaresolverrTestResult && ( -
- {flaresolverrTestResult.success ? 'โœ“ ' : 'โœ— '} - {flaresolverrTestResult.message} -
- )} -
- {!settings.ebook?.flaresolverrUrl && ( -
-

- Note: Without FlareSolverr, e-book downloads may fail if Anna's Archive - has Cloudflare protection enabled. Success rates are typically lower without it. -

-
- )} -
- )} - - {/* Info Box */} -
-

- How it works -

-
    -
  • โ€ข Searches Anna's Archive in two ways:
  • -
  • 1. First tries ASIN (exact match - most accurate)
  • -
  • 2. Falls back to title + author (with book/language filters)
  • -
  • โ€ข Downloads matching e-book in your preferred format
  • -
  • โ€ข Places e-book file in the same folder as the audiobook
  • -
  • โ€ข If no match is found or download fails, audiobook download continues normally
  • -
  • โ€ข Completely optional and non-blocking
  • -
-
- - {/* Warning Box */} -
-

- โš ๏ธ Important Note -

-

- Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility. - Ensure compliance with your local laws and regulations. -

-
- - {/* Save Button */} -
- -
-
- )} - - {/* BookDate Tab */} - {activeTab === 'bookdate' && ( -
-
-

- BookDate Configuration -

-

- Configure global AI-powered audiobook recommendations. All users share this API key, but receive personalized recommendations based on their individual library and ratings. -

-
- - {/* Enable/Disable Toggle */} - {bookdateConfigured && ( -
-
-
-

- BookDate Feature -

-

- {bookdateEnabled ? 'Feature is currently enabled' : 'Feature is currently disabled'} -

-
- -
-
- )} - - {/* AI Provider */} -
- - -
- - {/* Base URL Input - Show for Custom Provider */} - {bookdateProvider === 'custom' && ( -
- - { - setBookdateBaseUrl(e.target.value); - setBookdateModels([]); - }} - placeholder="http://localhost:11434/v1" - /> -

- Examples: -
โ€ข Ollama: http://localhost:11434/v1 -
โ€ข LM Studio: http://localhost:1234/v1 -
โ€ข vLLM: http://localhost:8000/v1 -

-
- )} - - {/* API Key */} -
- - { - setBookdateApiKey(e.target.value); - setBookdateModels([]); - }} - placeholder={ - bookdateProvider === 'custom' - ? 'Leave blank for local models' - : bookdateConfigured - ? 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข' - : (bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...') - } - /> -

- {bookdateProvider === 'custom' - ? 'Optional: Leave blank if your endpoint does not require authentication (e.g., Ollama, LM Studio)' - : 'The API key is stored securely and encrypted. Leave blank to keep existing key.'} -

-
- - {/* Test Connection Button */} - - - {/* Model Selection */} - {bookdateModels.length > 0 && ( -
- - -
- )} - - {/* Note about per-user settings */} - {(bookdateModels.length > 0 || bookdateConfigured) && bookdateModel && ( -
-

- Note: Library scope and custom prompt preferences are now configured per-user. - Users can adjust these settings in their BookDate preferences (settings icon on the BookDate page). -

-
- )} - - {/* Save Button */} - {bookdateModel && ( -
- -
- )} - - {/* Clear Swipe History */} - {bookdateConfigured && ( -
-

- Clear All Swipe History -

-

- Remove all swipe history and cached recommendations for ALL users. This will reset everyone's BookDate recommendations. -

- -
- )} -
- )} - - {/* Authentication Tab - Only visible in ABS mode */} - {activeTab === 'auth' && settings?.backendMode === 'audiobookshelf' && ( -
- {/* OIDC Settings Section */} -
-

- OIDC Authentication -

-

- Configure OpenID Connect (OIDC) authentication for single sign-on with Authentik, Keycloak, or other providers. -

- -
-
-
- { - setSettings({ - ...settings, - oidc: { ...settings.oidc, enabled: e.target.checked }, - }); - setValidated({ ...validated, oidc: false }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- Allow users to log in using an external OIDC provider -

-
-
-
- - {settings.oidc.enabled && ( - <> -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, providerName: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="Authentik" - /> -

- Display name for the login button -

-
- -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, issuerUrl: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="https://auth.example.com/application/o/readmeabook/" - /> -

- OIDC provider's issuer URL (must support .well-known/openid-configuration) -

-
- -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, clientId: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="readmeabook-client" - /> -
- -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, clientSecret: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="Enter client secret" - /> -
- -
- - {testResults.oidc && ( -
- {testResults.oidc.message} -
- )} -
- - {/* Access Control Section */} -
-

- Access Control -

-

- Control who can log in to your application. This is separate from admin permissions. -

- -
-
- - -

- {settings.oidc.accessControlMethod === 'open' && 'Anyone who can authenticate with your OIDC provider will have access'} - {settings.oidc.accessControlMethod === 'group_claim' && 'Only users with a specific group/claim can access'} - {settings.oidc.accessControlMethod === 'allowed_list' && 'Only explicitly allowed users can access'} - {settings.oidc.accessControlMethod === 'admin_approval' && 'New users must be approved by an admin before access is granted'} -

-
- - {settings.oidc.accessControlMethod === 'group_claim' && ( - <> -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, accessGroupClaim: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="groups" - /> -

- The OIDC claim field that contains group membership (usually "groups" or "roles") -

-
- -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, accessGroupValue: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="readmeabook-users" - /> -

- Users must be in this group to access the application -

-
- - )} - - {settings.oidc.accessControlMethod === 'allowed_list' && ( - <> -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, allowedEmails: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="user1@example.com, user2@example.com" - /> -

- Enter email addresses separated by commas -

-
- -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, allowedUsernames: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="john_doe, jane_smith" - /> -

- Enter usernames separated by commas -

-
- - )} -
-
- - {/* Admin Role Mapping Section */} -
-

- Admin Role Mapping -

-

- Automatically grant admin permissions based on OIDC claims (e.g., group membership). The first user will always become admin. -

- -
-
- { - setSettings({ - ...settings, - oidc: { ...settings.oidc, adminClaimEnabled: e.target.checked }, - }); - setValidated({ ...validated, oidc: false }); - }} - className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" - /> -
- -

- Automatically grant admin role to users with specific OIDC claim values -

-
-
- - {settings.oidc.adminClaimEnabled && ( - <> -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, adminClaimName: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="groups" - /> -

- The OIDC claim field to check for admin role (usually "groups" or "roles") -

-
- -
- - { - setSettings({ - ...settings, - oidc: { ...settings.oidc, adminClaimValue: e.target.value }, - }); - setValidated({ ...validated, oidc: false }); - }} - placeholder="readmeabook-admin" - /> -

- Users with this value in their claim will be granted admin role -

-
- -
-
- - - -
-

- Example Configuration -

-

- In Authentik: Create a group called "readmeabook-admin", add users to it, and set "Admin Claim Value" to "readmeabook-admin" -

-
-
-
- - )} -
-
- - )} -
-
- - {/* Registration Settings Section */} -
-

- Manual Registration -

-

- Configure manual user registration settings. -

- -
-
-
- { - setSettings({ - ...settings, - registration: { ...settings.registration, enabled: e.target.checked }, - }); - setValidated({ ...validated, registration: false }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- Allow users to create accounts manually with username/password -

-
-
-
- - {settings.registration.enabled && ( -
-
- { - setSettings({ - ...settings, - registration: { ...settings.registration, requireAdminApproval: e.target.checked }, - }); - setValidated({ ...validated, registration: false }); - }} - className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" - /> -
- -

- New users must be approved by an admin before they can log in -

-
-
-
- )} -
-
- - {/* Warning: No auth methods enabled AND no local users exist */} - {settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers && ( -
-
- - - -
-

- No Authentication Methods Available -

-

- You must enable at least one authentication method (OIDC or Manual Registration) since no local users exist. - Saving with both disabled will lock you out of the system. -

-
-
-
- )} - - {/* Info: Registration disabled but local users can still log in */} - {settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && settings.hasLocalUsers && ( -
-
- - - -
-

- Manual Registration Disabled -

-

- New user registration is disabled. Existing local users can still log in with their credentials. -

-
-
-
- )} - - {/* Pending Users Section */} - {settings.registration.enabled && settings.registration.requireAdminApproval && ( -
-

- Pending User Approvals -

-

- Review and approve or reject user registration requests. -

- - {loadingPendingUsers ? ( -
-
- Loading pending users... -
- ) : pendingUsers.length > 0 ? ( -
- {pendingUsers.map((user) => ( -
-
-
-

- {user.plexUsername} -

- {user.plexEmail && ( -

- {user.plexEmail} -

- )} -

- Registered: {new Date(user.createdAt).toLocaleDateString()} -

-
-
- - -
-
-
- ))} -
- ) : ( -
-

- No pending user approvals -

-
- )} -
- )} -
- )} - -
- - {/* Footer - Hide for BookDate and E-book tabs (they have their own save buttons) */} - {activeTab !== 'bookdate' && activeTab !== 'ebook' && ( -
-
- - -
- {(() => { - // For Library tab: check based on backend mode - if (activeTab === 'library' && settings) { - if (settings.backendMode === 'plex' && !validated.plex) { - return ( -

- Please test your connection before saving -

- ); - } - if (settings.backendMode === 'audiobookshelf' && !validated.audiobookshelf) { - return ( -

- Please test your connection before saving -

- ); - } - } - // For Auth tab: no validation message (toggles don't need testing) - if (activeTab === 'auth') { - return null; - } - // For Prowlarr: show message only if URL/API key changed and not validated - if (activeTab === 'prowlarr' && originalSettings && settings) { - const connectionChanged = - settings.prowlarr.url !== originalSettings.prowlarr.url || - settings.prowlarr.apiKey !== originalSettings.prowlarr.apiKey; - if (connectionChanged && !validated.prowlarr) { - return ( -

- Please test your connection before saving -

- ); - } - } - // For other tabs: show message if not validated - if (activeTab === 'download' && !validated.download) { - return ( -

- Please test your connection before saving -

- ); - } - if (activeTab === 'paths' && !validated.paths) { - return ( -

- Please test paths before saving -

- ); - } - return null; - })()} + {/* Tab Content */} +
+ {/* Library Tab */} + {activeTab === 'library' && ( + { + setValidated({ ...validated, [section]: isValid }); + }} + onSuccess={(msg) => setMessage({ type: 'success', text: msg })} + onError={(msg) => setMessage({ type: 'error', text: msg })} + /> + )} + + {/* Auth Tab (only in Audiobookshelf mode) */} + {activeTab === 'auth' && settings?.backendMode === 'audiobookshelf' && ( + { + setValidated({ ...validated, [section]: isValid }); + }} + onSuccess={(msg) => setMessage({ type: 'success', text: msg })} + onError={(msg) => setMessage({ type: 'error', text: msg })} + /> + )} + + {/* Indexers Tab */} + {activeTab === 'prowlarr' && ( + setValidated({ ...validated, prowlarr: isValid })} + onRefreshIndexers={() => fetchIndexers(true)} + /> + )} + + {/* Download Client Tab */} + {activeTab === 'download' && ( + setSettings({ ...settings, downloadClient: dc })} + onValidationChange={(isValid) => setValidated({ ...validated, download: isValid })} + /> + )} + + {/* Paths Tab */} + {activeTab === 'paths' && ( + setSettings({ ...settings, paths })} + onValidationChange={(isValid) => setValidated({ ...validated, paths: isValid })} + /> + )} + + {/* E-book Sidecar Tab */} + {activeTab === 'ebook' && ( + setSettings({ ...settings, ebook })} + onSuccess={(msg) => setMessage({ type: 'success', text: msg })} + onError={(msg) => setMessage({ type: 'error', text: msg })} + markAsSaved={() => setOriginalSettings(JSON.parse(JSON.stringify(settings)))} + /> + )} + + {/* BookDate Tab */} + {activeTab === 'bookdate' && ( + setMessage({ type: 'success', text: msg })} + onError={(msg) => setMessage({ type: 'error', text: msg })} + /> + )} + + {/* Save Button (only for tabs that save through main page) */} + {activeTab !== 'ebook' && activeTab !== 'bookdate' && ( +
+ + {!currentTabValidation && hasUnsavedChanges && ( +

+ Please test the connection before saving +

+ )}
)}
diff --git a/src/app/admin/settings/tabs/AuthTab/AuthTab.tsx b/src/app/admin/settings/tabs/AuthTab/AuthTab.tsx new file mode 100644 index 0000000..20770c6 --- /dev/null +++ b/src/app/admin/settings/tabs/AuthTab/AuthTab.tsx @@ -0,0 +1,154 @@ +/** + * Component: AuthTab - Authentication Settings + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import { useEffect } from 'react'; +import { OIDCSection } from './OIDCSection'; +import { RegistrationSection } from './RegistrationSection'; +import { PendingUsersTable } from './PendingUsersTable'; +import { useAuthSettings } from './useAuthSettings'; +import type { Settings } from '../../lib/types'; + +interface AuthTabProps { + settings: Settings; + onChange: (settings: Settings) => void; + onValidationChange: (section: string, isValid: boolean) => void; + onSuccess: (message: string) => void; + onError: (message: string) => void; +} + +export function AuthTab({ + settings, + onChange, + onValidationChange, + onSuccess, + onError +}: AuthTabProps) { + const { + pendingUsers, + loadingPendingUsers, + testing, + oidcTestResult, + fetchPendingUsers, + testOIDCConnection, + approveUser, + } = useAuthSettings({ onSuccess, onError }); + + // Fetch pending users when the tab is loaded and registration with approval is enabled + useEffect(() => { + if (settings.registration.enabled && settings.registration.requireAdminApproval) { + fetchPendingUsers(); + } + }, [settings.registration.enabled, settings.registration.requireAdminApproval, fetchPendingUsers]); + + const handleOIDCChange = (oidcSettings: typeof settings.oidc) => { + onChange({ + ...settings, + oidc: oidcSettings, + }); + onValidationChange('oidc', false); + }; + + const handleRegistrationChange = (registrationSettings: typeof settings.registration) => { + onChange({ + ...settings, + registration: registrationSettings, + }); + onValidationChange('registration', false); + }; + + const handleOIDCTest = async (issuerUrl: string, clientId: string, clientSecret: string) => { + const isValid = await testOIDCConnection(issuerUrl, clientId, clientSecret); + if (isValid) { + onValidationChange('oidc', true); + } + return isValid; + }; + + // Check if no auth methods are enabled and no local users exist + const showNoAuthWarning = settings.backendMode === 'audiobookshelf' && + !settings.oidc.enabled && + !settings.registration.enabled && + !settings.hasLocalUsers; + + // Check if registration is disabled but local users can still log in + const showRegistrationDisabledInfo = settings.backendMode === 'audiobookshelf' && + !settings.oidc.enabled && + !settings.registration.enabled && + settings.hasLocalUsers; + + // Show pending users table if registration with approval is enabled + const showPendingUsers = settings.registration.enabled && + settings.registration.requireAdminApproval; + + return ( +
+ {/* OIDC Settings Section */} + onValidationChange('oidc', true)} + /> + + {/* Registration Settings Section */} + + + {/* Warning: No auth methods enabled AND no local users exist */} + {showNoAuthWarning && ( +
+
+ + + +
+

+ No Authentication Methods Available +

+

+ You must enable at least one authentication method (OIDC or Manual Registration) since no local users exist. + Saving with both disabled will lock you out of the system. +

+
+
+
+ )} + + {/* Info: Registration disabled but local users can still log in */} + {showRegistrationDisabledInfo && ( +
+
+ + + +
+

+ Manual Registration Disabled +

+

+ New user registration is disabled. Existing local users can still log in with their credentials. +

+
+
+
+ )} + + {/* Pending Users Section */} + {showPendingUsers && ( + + )} +
+ ); +} diff --git a/src/app/admin/settings/tabs/AuthTab/OIDCSection.tsx b/src/app/admin/settings/tabs/AuthTab/OIDCSection.tsx new file mode 100644 index 0000000..0be50e3 --- /dev/null +++ b/src/app/admin/settings/tabs/AuthTab/OIDCSection.tsx @@ -0,0 +1,378 @@ +/** + * Component: AuthTab - OIDC Configuration Section + * Documentation: documentation/settings-pages.md + */ + +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import type { OIDCSettings, TestResult } from '../../lib/types'; + +interface OIDCSectionProps { + settings: OIDCSettings; + onChange: (settings: OIDCSettings) => void; + onTest: (issuerUrl: string, clientId: string, clientSecret: string) => Promise; + testing: boolean; + testResult: TestResult | null; + onValidationChange: () => void; +} + +export function OIDCSection({ + settings, + onChange, + onTest, + testing, + testResult, + onValidationChange +}: OIDCSectionProps) { + const handleTestConnection = async () => { + const isValid = await onTest(settings.issuerUrl, settings.clientId, settings.clientSecret); + if (isValid) { + onValidationChange(); + } + }; + + return ( +
+

+ OIDC Authentication +

+

+ Configure OpenID Connect (OIDC) authentication for single sign-on with Authentik, Keycloak, or other providers. +

+ +
+ {/* Enable OIDC Toggle */} +
+
+ { + onChange({ ...settings, enabled: e.target.checked }); + }} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Allow users to log in using an external OIDC provider +

+
+
+
+ + {settings.enabled && ( + <> + {/* Provider Name */} +
+ + { + onChange({ ...settings, providerName: e.target.value }); + }} + placeholder="Authentik" + /> +

+ Display name for the login button +

+
+ + {/* Issuer URL */} +
+ + { + onChange({ ...settings, issuerUrl: e.target.value }); + }} + placeholder="https://auth.example.com/application/o/readmeabook/" + /> +

+ OIDC provider's issuer URL (must support .well-known/openid-configuration) +

+
+ + {/* Client ID */} +
+ + { + onChange({ ...settings, clientId: e.target.value }); + }} + placeholder="readmeabook-client" + /> +
+ + {/* Client Secret */} +
+ + { + onChange({ ...settings, clientSecret: e.target.value }); + }} + placeholder="Enter client secret" + /> +
+ + {/* Test Connection Button */} +
+ + {testResult && ( +
+ {testResult.message} +
+ )} +
+ + {/* Access Control Section */} +
+

+ Access Control +

+

+ Control who can log in to your application. This is separate from admin permissions. +

+ +
+ {/* Access Control Method */} +
+ + +

+ {settings.accessControlMethod === 'open' && 'Anyone who can authenticate with your OIDC provider will have access'} + {settings.accessControlMethod === 'group_claim' && 'Only users with a specific group/claim can access'} + {settings.accessControlMethod === 'allowed_list' && 'Only explicitly allowed users can access'} + {settings.accessControlMethod === 'admin_approval' && 'New users must be approved by an admin before access is granted'} +

+
+ + {/* Group/Claim Based Controls */} + {settings.accessControlMethod === 'group_claim' && ( + <> +
+ + { + onChange({ ...settings, accessGroupClaim: e.target.value }); + }} + placeholder="groups" + /> +

+ The OIDC claim field that contains group membership (usually "groups" or "roles") +

+
+ +
+ + { + onChange({ ...settings, accessGroupValue: e.target.value }); + }} + placeholder="readmeabook-users" + /> +

+ Users must be in this group to access the application +

+
+ + )} + + {/* Allowed List Controls */} + {settings.accessControlMethod === 'allowed_list' && ( + <> +
+ + { + onChange({ ...settings, allowedEmails: e.target.value }); + }} + placeholder="user1@example.com, user2@example.com" + /> +

+ Enter email addresses separated by commas +

+
+ +
+ + { + onChange({ ...settings, allowedUsernames: e.target.value }); + }} + placeholder="john_doe, jane_smith" + /> +

+ Enter usernames separated by commas +

+
+ + )} +
+
+ + {/* Admin Role Mapping Section */} +
+

+ Admin Role Mapping +

+

+ Automatically grant admin permissions based on OIDC claims (e.g., group membership). The first user will always become admin. +

+ +
+ {/* Enable Admin Claim Mapping */} +
+ { + onChange({ ...settings, adminClaimEnabled: e.target.checked }); + }} + className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500" + /> +
+ +

+ Automatically grant admin role to users with specific OIDC claim values +

+
+
+ + {settings.adminClaimEnabled && ( + <> +
+ + { + onChange({ ...settings, adminClaimName: e.target.value }); + }} + placeholder="groups" + /> +

+ The OIDC claim field to check for admin role (usually "groups" or "roles") +

+
+ +
+ + { + onChange({ ...settings, adminClaimValue: e.target.value }); + }} + placeholder="readmeabook-admin" + /> +

+ Users with this value in their claim will be granted admin role +

+
+ + {/* Example Configuration */} +
+
+ + + +
+

+ Example Configuration +

+

+ In Authentik: Create a group called "readmeabook-admin", add users to it, and set "Admin Claim Value" to "readmeabook-admin" +

+
+
+
+ + )} +
+
+ + )} +
+
+ ); +} diff --git a/src/app/admin/settings/tabs/AuthTab/PendingUsersTable.tsx b/src/app/admin/settings/tabs/AuthTab/PendingUsersTable.tsx new file mode 100644 index 0000000..e4d6c00 --- /dev/null +++ b/src/app/admin/settings/tabs/AuthTab/PendingUsersTable.tsx @@ -0,0 +1,80 @@ +/** + * Component: AuthTab - Pending Users Table + * Documentation: documentation/settings-pages.md + */ + +import { Button } from '@/components/ui/Button'; +import type { PendingUser } from '../../lib/types'; + +interface PendingUsersTableProps { + pendingUsers: PendingUser[]; + loading: boolean; + onApprove: (userId: string, approve: boolean) => void; +} + +export function PendingUsersTable({ pendingUsers, loading, onApprove }: PendingUsersTableProps) { + return ( +
+

+ Pending User Approvals +

+

+ Review and approve or reject user registration requests. +

+ + {loading ? ( +
+
+ Loading pending users... +
+ ) : pendingUsers.length > 0 ? ( +
+ {pendingUsers.map((user) => ( +
+
+
+

+ {user.plexUsername} +

+ {user.plexEmail && ( +

+ {user.plexEmail} +

+ )} +

+ Registered: {new Date(user.createdAt).toLocaleDateString()} +

+
+
+ + +
+
+
+ ))} +
+ ) : ( +
+

+ No pending user approvals +

+
+ )} +
+ ); +} diff --git a/src/app/admin/settings/tabs/AuthTab/RegistrationSection.tsx b/src/app/admin/settings/tabs/AuthTab/RegistrationSection.tsx new file mode 100644 index 0000000..90edc7f --- /dev/null +++ b/src/app/admin/settings/tabs/AuthTab/RegistrationSection.tsx @@ -0,0 +1,80 @@ +/** + * Component: AuthTab - Manual Registration Section + * Documentation: documentation/settings-pages.md + */ + +import type { RegistrationSettings } from '../../lib/types'; + +interface RegistrationSectionProps { + settings: RegistrationSettings; + onChange: (settings: RegistrationSettings) => void; +} + +export function RegistrationSection({ settings, onChange }: RegistrationSectionProps) { + return ( +
+

+ Manual Registration +

+

+ Configure manual user registration settings. +

+ +
+ {/* Enable Registration Toggle */} +
+
+ { + onChange({ ...settings, enabled: e.target.checked }); + }} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Allow users to create accounts manually with username/password +

+
+
+
+ + {/* Require Admin Approval Toggle */} + {settings.enabled && ( +
+
+ { + onChange({ ...settings, requireAdminApproval: e.target.checked }); + }} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ New users must be approved by an admin before they can log in +

+
+
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/settings/tabs/AuthTab/index.ts b/src/app/admin/settings/tabs/AuthTab/index.ts new file mode 100644 index 0000000..9000c93 --- /dev/null +++ b/src/app/admin/settings/tabs/AuthTab/index.ts @@ -0,0 +1,10 @@ +/** + * Component: AuthTab - Export File + * Documentation: documentation/settings-pages.md + */ + +export { AuthTab } from './AuthTab'; +export { OIDCSection } from './OIDCSection'; +export { RegistrationSection } from './RegistrationSection'; +export { PendingUsersTable } from './PendingUsersTable'; +export { useAuthSettings } from './useAuthSettings'; diff --git a/src/app/admin/settings/tabs/AuthTab/useAuthSettings.ts b/src/app/admin/settings/tabs/AuthTab/useAuthSettings.ts new file mode 100644 index 0000000..89aa0e0 --- /dev/null +++ b/src/app/admin/settings/tabs/AuthTab/useAuthSettings.ts @@ -0,0 +1,114 @@ +/** + * Component: AuthTab - Custom Hook + * Documentation: documentation/settings-pages.md + */ + +import { useState, useEffect, useCallback } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; +import type { PendingUser, TestResult } from '../../lib/types'; + +interface UseAuthSettingsProps { + onSuccess: (message: string) => void; + onError: (message: string) => void; +} + +export function useAuthSettings({ onSuccess, onError }: UseAuthSettingsProps) { + const [pendingUsers, setPendingUsers] = useState([]); + const [loadingPendingUsers, setLoadingPendingUsers] = useState(false); + const [testing, setTesting] = useState(false); + const [oidcTestResult, setOidcTestResult] = useState(null); + + /** + * Fetch pending users awaiting approval + */ + const fetchPendingUsers = useCallback(async () => { + setLoadingPendingUsers(true); + try { + const response = await fetchWithAuth('/api/admin/users/pending'); + if (response.ok) { + const data = await response.json(); + setPendingUsers(data.users || []); + } else { + console.error('Failed to fetch pending users:', response.status); + } + } catch (error) { + console.error('Failed to fetch pending users:', error); + } finally { + setLoadingPendingUsers(false); + } + }, []); + + /** + * Test OIDC connection configuration + */ + const testOIDCConnection = useCallback(async (issuerUrl: string, clientId: string, clientSecret: string) => { + setTesting(true); + setOidcTestResult(null); + + try { + const response = await fetchWithAuth('/api/setup/test-oidc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issuerUrl, + clientId, + clientSecret, + }), + }); + + const data = await response.json(); + + if (data.success) { + setOidcTestResult({ success: true, message: 'OIDC configuration is valid' }); + onSuccess('OIDC configuration is valid. You can now save.'); + return true; + } else { + setOidcTestResult({ success: false, message: data.error || 'Connection failed' }); + onError(data.error || 'Failed to validate OIDC configuration'); + return false; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to test OIDC connection'; + setOidcTestResult({ success: false, message: errorMsg }); + onError(errorMsg); + return false; + } finally { + setTesting(false); + } + }, [onSuccess, onError]); + + /** + * Approve or reject a pending user + */ + const approveUser = useCallback(async (userId: string, approve: boolean) => { + try { + const response = await fetchWithAuth(`/api/admin/users/${userId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approve }), + }); + + const data = await response.json(); + + if (data.success) { + onSuccess(data.message); + // Refresh pending users list + await fetchPendingUsers(); + } else { + onError(data.error || 'Failed to process user approval'); + } + } catch (error) { + onError(error instanceof Error ? error.message : 'Failed to process user approval'); + } + }, [onSuccess, onError, fetchPendingUsers]); + + return { + pendingUsers, + loadingPendingUsers, + testing, + oidcTestResult, + fetchPendingUsers, + testOIDCConnection, + approveUser, + }; +} diff --git a/src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx b/src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx new file mode 100644 index 0000000..6b1aade --- /dev/null +++ b/src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx @@ -0,0 +1,232 @@ +/** + * Component: BookDate Settings Tab + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { useBookDateSettings } from './useBookDateSettings'; + +interface BookDateTabProps { + onSuccess: (message: string) => void; + onError: (message: string) => void; +} + +export function BookDateTab({ onSuccess, onError }: BookDateTabProps) { + const { + provider, + apiKey, + model, + baseUrl, + enabled, + configured, + models, + testing, + saving, + clearingSwipes, + setProvider, + setApiKey, + setModel, + setBaseUrl, + setEnabled, + setModels, + testConnection, + saveConfig, + clearSwipes, + } = useBookDateSettings(); + + return ( +
+
+

+ BookDate Configuration +

+

+ Configure global AI-powered audiobook recommendations. All users share this API key, but receive personalized recommendations based on their individual library and ratings. +

+
+ + {/* Enable/Disable Toggle */} + {configured && ( +
+
+
+

+ BookDate Feature +

+

+ {enabled ? 'Feature is currently enabled' : 'Feature is currently disabled'} +

+
+ +
+
+ )} + + {/* AI Provider */} +
+ + +
+ + {/* Base URL Input - Show for Custom Provider */} + {provider === 'custom' && ( +
+ + { + setBaseUrl(e.target.value); + setModels([]); + }} + placeholder="http://localhost:11434/v1" + /> +

+ Examples: +
โ€ข Ollama: http://localhost:11434/v1 +
โ€ข LM Studio: http://localhost:1234/v1 +
โ€ข vLLM: http://localhost:8000/v1 +

+
+ )} + + {/* API Key */} +
+ + { + setApiKey(e.target.value); + setModels([]); + }} + placeholder={ + provider === 'custom' + ? 'Leave blank for local models' + : configured + ? 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข' + : (provider === 'openai' ? 'sk-...' : 'sk-ant-...') + } + /> +

+ {provider === 'custom' + ? 'Optional: Leave blank if your endpoint does not require authentication (e.g., Ollama, LM Studio)' + : 'The API key is stored securely and encrypted. Leave blank to keep existing key.'} +

+
+ + {/* Test Connection Button */} + + + {/* Model Selection */} + {models.length > 0 && ( +
+ + +
+ )} + + {/* Note about per-user settings */} + {(models.length > 0 || configured) && model && ( +
+

+ Note: Library scope and custom prompt preferences are now configured per-user. + Users can adjust these settings in their BookDate preferences (settings icon on the BookDate page). +

+
+ )} + + {/* Save Button */} + {model && ( +
+ +
+ )} + + {/* Clear Swipe History */} + {configured && ( +
+

+ Clear All Swipe History +

+

+ Remove all swipe history and cached recommendations for ALL users. This will reset everyone's BookDate recommendations. +

+ +
+ )} +
+ ); +} diff --git a/src/app/admin/settings/tabs/BookDateTab/index.ts b/src/app/admin/settings/tabs/BookDateTab/index.ts new file mode 100644 index 0000000..99be762 --- /dev/null +++ b/src/app/admin/settings/tabs/BookDateTab/index.ts @@ -0,0 +1,2 @@ +export { BookDateTab } from './BookDateTab'; +export { useBookDateSettings } from './useBookDateSettings'; diff --git a/src/app/admin/settings/tabs/BookDateTab/useBookDateSettings.ts b/src/app/admin/settings/tabs/BookDateTab/useBookDateSettings.ts new file mode 100644 index 0000000..3d46cf0 --- /dev/null +++ b/src/app/admin/settings/tabs/BookDateTab/useBookDateSettings.ts @@ -0,0 +1,233 @@ +/** + * Component: BookDate Settings Tab - Custom Hook + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; +import type { BookDateModel } from '../../lib/types'; + +export function useBookDateSettings() { + const [provider, setProvider] = useState('openai'); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [enabled, setEnabled] = useState(true); + const [configured, setConfigured] = useState(false); + const [models, setModels] = useState([]); + const [testing, setTesting] = useState(false); + const [saving, setSaving] = useState(false); + const [clearingSwipes, setClearingSwipes] = useState(false); + + /** + * Fetch BookDate configuration + */ + const fetchConfig = async () => { + try { + const response = await fetchWithAuth('/api/bookdate/config'); + const data = await response.json(); + + if (data.config) { + setProvider(data.config.provider || 'openai'); + setModel(data.config.model || ''); + setBaseUrl(data.config.baseUrl || ''); + setEnabled(data.config.isEnabled !== false); + setConfigured(data.config.isVerified || false); + } + } catch (error) { + console.error('Failed to load BookDate config:', error); + } + }; + + /** + * Test connection and fetch available models + */ + const testConnection = async (onSuccess: (msg: string) => void, onError: (msg: string) => void) => { + const hasApiKey = apiKey.trim().length > 0; + + // Validation + if (provider === 'custom') { + if (!baseUrl.trim()) { + onError('Please enter a base URL for custom provider'); + return; + } + } else { + if (!hasApiKey && !configured) { + onError('Please enter an API key'); + return; + } + } + + setTesting(true); + + try { + const payload: any = { + provider, + }; + + if (hasApiKey) { + payload.apiKey = apiKey; + } else if (provider !== 'custom') { + payload.useSavedKey = true; + } + + if (provider === 'custom') { + payload.baseUrl = baseUrl; + } + + const response = await fetchWithAuth('/api/bookdate/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Connection test failed'); + } + + setModels(data.models || []); + onSuccess('Connection successful! Please select a model.'); + + // Auto-select first model if none selected + if (!model && data.models?.length > 0) { + setModel(data.models[0].id); + } + } catch (error) { + onError(error instanceof Error ? error.message : 'Connection test failed'); + } finally { + setTesting(false); + } + }; + + /** + * Save BookDate configuration + */ + const saveConfig = async (onSuccess: (msg: string) => void, onError: (msg: string) => void) => { + // Validate: model is required + if (!model) { + onError('Please select a model'); + return; + } + + // Validate: baseUrl required for custom provider + if (provider === 'custom') { + if (!baseUrl.trim()) { + onError('Please enter a base URL for custom provider'); + return; + } + } else { + const hasApiKey = apiKey.trim().length > 0; + if (!configured && !hasApiKey) { + onError('Please enter an API key for initial setup'); + return; + } + } + + setSaving(true); + + try { + const hasApiKey = apiKey.trim().length > 0; + const payload: any = { + provider, + model, + isEnabled: enabled, + }; + + if (hasApiKey) { + payload.apiKey = apiKey; + } + + if (provider === 'custom') { + payload.baseUrl = baseUrl; + } + + const response = await fetchWithAuth('/api/bookdate/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to save configuration'); + } + + onSuccess('BookDate configuration saved successfully!'); + setConfigured(true); + setApiKey(''); // Clear API key from UI after save + } catch (error) { + onError(error instanceof Error ? error.message : 'Failed to save configuration'); + } finally { + setSaving(false); + } + }; + + /** + * Clear all swipe history + */ + const clearSwipes = async (onSuccess: (msg: string) => void, onError: (msg: string) => void) => { + if (!confirm('This will clear all swipe history. Continue?')) { + return; + } + + setClearingSwipes(true); + + try { + const response = await fetchWithAuth('/api/bookdate/swipes', { + method: 'DELETE', + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to clear swipe history'); + } + + onSuccess('Swipe history cleared successfully!'); + } catch (error) { + onError(error instanceof Error ? error.message : 'Failed to clear swipe history'); + } finally { + setClearingSwipes(false); + } + }; + + /** + * Handle provider change + */ + const handleProviderChange = (newProvider: string) => { + setProvider(newProvider); + setModels([]); + setBaseUrl(''); + }; + + useEffect(() => { + fetchConfig(); + }, []); + + return { + provider, + apiKey, + model, + baseUrl, + enabled, + configured, + models, + testing, + saving, + clearingSwipes, + setProvider: handleProviderChange, + setApiKey, + setModel, + setBaseUrl, + setEnabled, + setModels, + testConnection, + saveConfig, + clearSwipes, + }; +} diff --git a/src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx b/src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx new file mode 100644 index 0000000..265b6e3 --- /dev/null +++ b/src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx @@ -0,0 +1,235 @@ +/** + * Component: Download Client Settings Tab + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import React from 'react'; +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { useDownloadSettings } from './useDownloadSettings'; +import type { DownloadClientSettings } from '../../lib/types'; + +interface DownloadTabProps { + downloadClient: DownloadClientSettings; + onChange: (settings: DownloadClientSettings) => void; + onValidationChange: (isValid: boolean) => void; +} + +export function DownloadTab({ downloadClient, onChange, onValidationChange }: DownloadTabProps) { + const { testing, testResult, updateField, handleTypeChange, testConnection } = useDownloadSettings({ + downloadClient, + onChange, + onValidationChange, + }); + + return ( +
+
+

+ Download Client +

+

+ Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads. +

+
+ +
+ + +
+ +
+ + updateField('url', e.target.value)} + placeholder="http://localhost:8080" + /> +
+ + {/* qBittorrent: Username + Password */} + {downloadClient.type === 'qbittorrent' && ( + <> +
+ + updateField('username', e.target.value)} + placeholder="admin" + /> +
+ +
+ + updateField('password', e.target.value)} + placeholder="Enter password" + /> +
+ + )} + + {/* SABnzbd: API Key only */} + {downloadClient.type === 'sabnzbd' && ( +
+ + updateField('password', e.target.value)} + placeholder="Enter SABnzbd API key" + /> +

+ Find this in SABnzbd under Config โ†’ General โ†’ API Key +

+
+ )} + + {/* SSL Verification Toggle */} + {downloadClient.url.startsWith('https') && ( +
+
+ updateField('disableSSLVerify', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" + /> +
+ +

+ Enable this if you're using a self-signed certificate or getting SSL errors. + โš ๏ธ Only use on trusted private networks. +

+
+
+
+ )} + + {/* Remote Path Mapping */} +
+
+ updateField('remotePathMappingEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700" + /> +
+ +

+ Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers) +

+

+ Example: Remote /remote/mnt/d/done โ†’ Local /downloads +

+ + {/* Warning for existing downloads */} + {downloadClient.remotePathMappingEnabled && ( +
+

+ โš ๏ธ Note: Path mapping only affects new downloads. In-progress downloads will continue using their original paths. +

+
+ )} + + {/* Conditional Fields */} + {downloadClient.remotePathMappingEnabled && ( +
+
+ + updateField('remotePath', e.target.value)} + /> +

+ The path prefix as reported by qBittorrent +

+
+ +
+ + updateField('localPath', e.target.value)} + /> +

+ The actual path where files are accessible +

+
+
+ )} +
+
+
+ +
+ + {testResult && ( +
+ {testResult.message} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/settings/tabs/DownloadTab/index.ts b/src/app/admin/settings/tabs/DownloadTab/index.ts new file mode 100644 index 0000000..20a7f63 --- /dev/null +++ b/src/app/admin/settings/tabs/DownloadTab/index.ts @@ -0,0 +1,6 @@ +/** + * Component: Download Client Settings Tab - Export + * Documentation: documentation/settings-pages.md + */ + +export { DownloadTab } from './DownloadTab'; diff --git a/src/app/admin/settings/tabs/DownloadTab/useDownloadSettings.ts b/src/app/admin/settings/tabs/DownloadTab/useDownloadSettings.ts new file mode 100644 index 0000000..5a5d5d7 --- /dev/null +++ b/src/app/admin/settings/tabs/DownloadTab/useDownloadSettings.ts @@ -0,0 +1,96 @@ +/** + * Component: Download Client Settings Tab - Custom Hook + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import { useState } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; +import type { DownloadClientSettings, TestResult } from '../../lib/types'; + +interface UseDownloadSettingsProps { + downloadClient: DownloadClientSettings; + onChange: (settings: DownloadClientSettings) => void; + onValidationChange: (isValid: boolean) => void; +} + +export function useDownloadSettings({ downloadClient, onChange, onValidationChange }: UseDownloadSettingsProps) { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + const updateField = (field: keyof DownloadClientSettings, value: string | boolean) => { + onChange({ ...downloadClient, [field]: value }); + onValidationChange(false); + }; + + const handleTypeChange = (type: string) => { + onChange({ + ...downloadClient, + type, + username: '', + password: '', + }); + onValidationChange(false); + }; + + const testConnection = async () => { + setTesting(true); + setTestResult(null); + + try { + const response = await fetchWithAuth('/api/admin/settings/test-download-client', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: downloadClient.type, + url: downloadClient.url, + username: downloadClient.username, + password: downloadClient.password, + disableSSLVerify: downloadClient.disableSSLVerify, + remotePathMappingEnabled: downloadClient.remotePathMappingEnabled, + remotePath: downloadClient.remotePath, + localPath: downloadClient.localPath, + }), + }); + + const data = await response.json(); + + if (data.success) { + const result: TestResult = { + success: true, + message: `Connected to ${downloadClient.type} (${data.version || 'version unknown'})` + }; + setTestResult(result); + onValidationChange(true); + return result; + } else { + const result: TestResult = { + success: false, + message: data.error || 'Connection failed' + }; + setTestResult(result); + onValidationChange(false); + return result; + } + } catch (error) { + const result: TestResult = { + success: false, + message: error instanceof Error ? error.message : 'Failed to test connection' + }; + setTestResult(result); + onValidationChange(false); + return result; + } finally { + setTesting(false); + } + }; + + return { + testing, + testResult, + updateField, + handleTypeChange, + testConnection, + }; +} diff --git a/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx new file mode 100644 index 0000000..3293014 --- /dev/null +++ b/src/app/admin/settings/tabs/EbookTab/EbookTab.tsx @@ -0,0 +1,204 @@ +/** + * Component: E-book Settings Tab + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { useEbookSettings } from './useEbookSettings'; +import type { EbookSettings } from '../../lib/types'; + +interface EbookTabProps { + ebook: EbookSettings; + onChange: (ebook: EbookSettings) => void; + onSuccess: (message: string) => void; + onError: (message: string) => void; + markAsSaved: () => void; +} + +export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: EbookTabProps) { + const { + saving, + testingFlaresolverr, + flaresolverrTestResult, + updateEbook, + testFlaresolverrConnection, + saveSettings, + } = useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSaved }); + + return ( +
+
+

+ E-book Sidecar +

+

+ Automatically download e-books from Anna's Archive to accompany your audiobooks. + E-books are placed in the same folder as the audiobook files. +

+
+ + {/* Enable Toggle */} +
+
+ updateEbook('enabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ When enabled, the system will search for e-books matching your audiobook's ASIN + and download them to the same folder. +

+
+
+
+ + {/* Format Selection */} + {ebook.enabled && ( +
+ + +

+ EPUB is recommended for most e-readers. "Any format" will download the first available format. +

+
+ )} + + {/* Base URL (Advanced) */} + {ebook.enabled && ( +
+ + updateEbook('baseUrl', e.target.value)} + placeholder="https://annas-archive.li" + className="font-mono" + /> +

+ Change this if the primary Anna's Archive mirror is unavailable. +

+
+ )} + + {/* FlareSolverr (Optional - for Cloudflare bypass) */} + {ebook.enabled && ( +
+
+ +
+ updateEbook('flaresolverrUrl', e.target.value)} + placeholder="http://localhost:8191" + className="font-mono flex-1" + /> + +
+

+ FlareSolverr helps bypass Cloudflare protection on Anna's Archive. + Leave empty if not needed. +

+ {flaresolverrTestResult && ( +
+ {flaresolverrTestResult.success ? 'โœ“ ' : 'โœ— '} + {flaresolverrTestResult.message} +
+ )} +
+ {!ebook.flaresolverrUrl && ( +
+

+ Note: Without FlareSolverr, e-book downloads may fail if Anna's Archive + has Cloudflare protection enabled. Success rates are typically lower without it. +

+
+ )} +
+ )} + + {/* Info Box */} +
+

+ How it works +

+
    +
  • โ€ข Searches Anna's Archive in two ways:
  • +
  • 1. First tries ASIN (exact match - most accurate)
  • +
  • 2. Falls back to title + author (with book/language filters)
  • +
  • โ€ข Downloads matching e-book in your preferred format
  • +
  • โ€ข Places e-book file in the same folder as the audiobook
  • +
  • โ€ข If no match is found or download fails, audiobook download continues normally
  • +
  • โ€ข Completely optional and non-blocking
  • +
+
+ + {/* Warning Box */} +
+

+ โš ๏ธ Important Note +

+

+ Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility. + Ensure compliance with your local laws and regulations. +

+
+ + {/* Save Button */} +
+ +
+
+ ); +} diff --git a/src/app/admin/settings/tabs/EbookTab/index.ts b/src/app/admin/settings/tabs/EbookTab/index.ts new file mode 100644 index 0000000..2edc114 --- /dev/null +++ b/src/app/admin/settings/tabs/EbookTab/index.ts @@ -0,0 +1,2 @@ +export { EbookTab } from './EbookTab'; +export { useEbookSettings } from './useEbookSettings'; diff --git a/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts new file mode 100644 index 0000000..e57fbd7 --- /dev/null +++ b/src/app/admin/settings/tabs/EbookTab/useEbookSettings.ts @@ -0,0 +1,109 @@ +/** + * Component: E-book Settings Tab - Custom Hook + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import { useState } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; +import type { EbookSettings, TestResult } from '../../lib/types'; + +interface UseEbookSettingsProps { + ebook: EbookSettings; + onChange: (ebook: EbookSettings) => void; + onSuccess: (message: string) => void; + onError: (message: string) => void; + markAsSaved: () => void; +} + +export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSaved }: UseEbookSettingsProps) { + const [saving, setSaving] = useState(false); + const [testingFlaresolverr, setTestingFlaresolverr] = useState(false); + const [flaresolverrTestResult, setFlaresolverrTestResult] = useState(null); + + /** + * Update a single ebook field + */ + const updateEbook = (field: keyof EbookSettings, value: string | boolean) => { + onChange({ ...ebook, [field]: value }); + if (field === 'flaresolverrUrl') { + setFlaresolverrTestResult(null); + } + }; + + /** + * Test FlareSolverr connection + */ + const testFlaresolverrConnection = async () => { + if (!ebook.flaresolverrUrl) { + setFlaresolverrTestResult({ + success: false, + message: 'Please enter a FlareSolverr URL first', + }); + return; + } + + setTestingFlaresolverr(true); + setFlaresolverrTestResult(null); + + try { + const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: ebook.flaresolverrUrl }), + }); + + const result = await response.json(); + setFlaresolverrTestResult(result); + } catch (error) { + setFlaresolverrTestResult({ + success: false, + message: error instanceof Error ? error.message : 'Test failed', + }); + } finally { + setTestingFlaresolverr(false); + } + }; + + /** + * Save e-book settings to API + */ + const saveSettings = async () => { + setSaving(true); + + try { + const response = await fetchWithAuth('/api/admin/settings/ebook', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: ebook.enabled || false, + format: ebook.preferredFormat || 'epub', + baseUrl: ebook.baseUrl || 'https://annas-archive.li', + flaresolverrUrl: ebook.flaresolverrUrl || '', + }), + }); + + if (!response.ok) { + throw new Error('Failed to save e-book settings'); + } + + onSuccess('E-book sidecar settings saved successfully!'); + markAsSaved(); + setTimeout(() => onSuccess(''), 3000); + } catch (error) { + onError(error instanceof Error ? error.message : 'Failed to save e-book settings'); + } finally { + setSaving(false); + } + }; + + return { + saving, + testingFlaresolverr, + flaresolverrTestResult, + updateEbook, + testFlaresolverrConnection, + saveSettings, + }; +} diff --git a/src/app/admin/settings/tabs/IndexersTab.tsx b/src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx similarity index 70% rename from src/app/admin/settings/tabs/IndexersTab.tsx rename to src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx index 7d4e6e0..244a4a5 100644 --- a/src/app/admin/settings/tabs/IndexersTab.tsx +++ b/src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx @@ -5,55 +5,51 @@ 'use client'; -import React from 'react'; +import React, { useEffect } from 'react'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement'; import { FlagConfigRow } from '@/components/admin/FlagConfigRow'; import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm'; - -interface SavedIndexerConfig { - id: number; - name: string; - priority: number; - seedingTimeMinutes: number; - rssEnabled: boolean; - categories: number[]; -} +import { useIndexersSettings } from './useIndexersSettings'; +import type { Settings, SavedIndexerConfig } from '../../lib/types'; interface IndexersTabProps { - settings: { - prowlarr: { - url: string; - apiKey: string; - }; - }; - originalSettings?: { - prowlarr: { - url: string; - apiKey: string; - }; - } | null; + settings: Settings; indexers: SavedIndexerConfig[]; flagConfigs: IndexerFlagConfig[]; - onSettingsChange: (settings: any) => void; + onChange: (settings: Settings) => void; onIndexersChange: (indexers: SavedIndexerConfig[]) => void; onFlagConfigsChange: (configs: IndexerFlagConfig[]) => void; - onValidationChange: (validated: any) => void; - validated: { prowlarr?: boolean }; + onValidationChange: (isValid: boolean) => void; + onRefreshIndexers?: () => Promise; } export function IndexersTab({ settings, - originalSettings, indexers, flagConfigs, - onSettingsChange, + onChange, onIndexersChange, onFlagConfigsChange, onValidationChange, - validated, + onRefreshIndexers, }: IndexersTabProps) { + const { testing, testResult, testConnection } = useIndexersSettings({ + prowlarrUrl: settings.prowlarr.url, + prowlarrApiKey: settings.prowlarr.apiKey, + onValidationChange, + onRefreshIndexers, + }); + + // Auto-load indexers when component mounts if prowlarr is configured + useEffect(() => { + if (settings.prowlarr.url && settings.prowlarr.apiKey && onRefreshIndexers) { + onRefreshIndexers(); + } + // Only run on mount, not when settings change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
@@ -73,14 +69,11 @@ export function IndexersTab({ type="url" value={settings.prowlarr.url} onChange={(e) => { - onSettingsChange({ + onChange({ ...settings, prowlarr: { ...settings.prowlarr, url: e.target.value }, }); - // Only invalidate if URL actually changed from original - if (originalSettings && e.target.value !== originalSettings.prowlarr.url) { - onValidationChange({ ...validated, prowlarr: false }); - } + onValidationChange(false); }} placeholder="http://localhost:9696" /> @@ -94,14 +87,11 @@ export function IndexersTab({ type="password" value={settings.prowlarr.apiKey} onChange={(e) => { - onSettingsChange({ + onChange({ ...settings, prowlarr: { ...settings.prowlarr, apiKey: e.target.value }, }); - // Only invalidate if API key actually changed from original - if (originalSettings && e.target.value !== originalSettings.prowlarr.apiKey) { - onValidationChange({ ...validated, prowlarr: false }); - } + onValidationChange(false); }} placeholder="Enter API key" /> @@ -110,6 +100,27 @@ export function IndexersTab({

+
+ + {testResult && ( +
+ {testResult.message} +
+ )} +
+
void; + onRefreshIndexers?: () => Promise; +} + +export function useIndexersSettings({ + prowlarrUrl, + prowlarrApiKey, + onValidationChange, + onRefreshIndexers, +}: UseIndexersSettingsProps) { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + /** + * Test Prowlarr connection + */ + const testConnection = async () => { + setTesting(true); + setTestResult(null); + + try { + const response = await fetchWithAuth('/api/admin/settings/test-prowlarr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: prowlarrUrl, + apiKey: prowlarrApiKey, + }), + }); + + const data = await response.json(); + + if (data.success) { + onValidationChange(true); + setTestResult({ + success: true, + message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`, + }); + + // Refresh indexers from database if callback provided + if (onRefreshIndexers) { + await onRefreshIndexers(); + } + } else { + onValidationChange(false); + setTestResult({ + success: false, + message: data.error || 'Connection failed', + }); + } + } catch (error) { + onValidationChange(false); + const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; + setTestResult({ + success: false, + message: errorMsg, + }); + } finally { + setTesting(false); + } + }; + + return { + testing, + testResult, + testConnection, + }; +} diff --git a/src/app/admin/settings/tabs/LibraryTab/AudiobookshelfSection.tsx b/src/app/admin/settings/tabs/LibraryTab/AudiobookshelfSection.tsx new file mode 100644 index 0000000..42084c1 --- /dev/null +++ b/src/app/admin/settings/tabs/LibraryTab/AudiobookshelfSection.tsx @@ -0,0 +1,198 @@ +/** + * Component: Audiobookshelf Library Settings Section + * Documentation: documentation/settings-pages.md + */ + +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { Settings, ABSLibrary } from '../../lib/types'; + +interface AudiobookshelfSectionProps { + settings: Settings; + onChange: (settings: Settings) => void; + onValidationChange: (section: string, isValid: boolean) => void; + libraries: ABSLibrary[]; + testing: boolean; + testResult: { success: boolean; message: string } | null; + onTestConnection: () => void; +} + +export function AudiobookshelfSection({ + settings, + onChange, + onValidationChange, + libraries, + testing, + testResult, + onTestConnection, +}: AudiobookshelfSectionProps) { + const handleServerUrlChange = (serverUrl: string) => { + onChange({ + ...settings, + audiobookshelf: { ...settings.audiobookshelf, serverUrl }, + }); + onValidationChange('audiobookshelf', false); + }; + + const handleApiTokenChange = (apiToken: string) => { + onChange({ + ...settings, + audiobookshelf: { ...settings.audiobookshelf, apiToken }, + }); + onValidationChange('audiobookshelf', false); + }; + + const handleLibraryChange = (libraryId: string) => { + onChange({ + ...settings, + audiobookshelf: { ...settings.audiobookshelf, libraryId }, + }); + onValidationChange('audiobookshelf', false); + }; + + const handleTriggerScanChange = (triggerScanAfterImport: boolean) => { + onChange({ + ...settings, + audiobookshelf: { ...settings.audiobookshelf, triggerScanAfterImport }, + }); + }; + + const handleAudibleRegionChange = (audibleRegion: string) => { + onChange({ + ...settings, + audibleRegion, + }); + }; + + return ( +
+
+

+ Audiobookshelf Server +

+

+ Configure your Audiobookshelf server connection and audiobook library. +

+
+ +
+ + handleServerUrlChange(e.target.value)} + placeholder="http://localhost:13378" + /> +
+ +
+ + handleApiTokenChange(e.target.value)} + placeholder="Enter your Audiobookshelf API token" + /> +

+ Generate in Audiobookshelf: Settings โ†’ API Keys โ†’ Add API Key +

+
+ +
+ + {libraries.length > 0 ? ( + + ) : ( +
+ Test your connection to load libraries. +
+ )} +
+ +
+ +
+ + {/* Audible Region Selection */} +
+ + +

+ Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) + configuration in Audiobookshelf. This ensures accurate book matching and metadata. +

+
+ +
+ + {testResult && ( +
+ {testResult.message} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/settings/tabs/LibraryTab/LibraryTab.tsx b/src/app/admin/settings/tabs/LibraryTab/LibraryTab.tsx new file mode 100644 index 0000000..fe02480 --- /dev/null +++ b/src/app/admin/settings/tabs/LibraryTab/LibraryTab.tsx @@ -0,0 +1,80 @@ +/** + * Component: Library Settings Tab (Main) + * Documentation: documentation/settings-pages.md + */ + +import { Settings } from '../../lib/types'; +import { useLibrarySettings } from './useLibrarySettings'; +import { PlexSection } from './PlexSection'; +import { AudiobookshelfSection } from './AudiobookshelfSection'; + +interface LibraryTabProps { + settings: Settings; + onChange: (settings: Settings) => void; + onValidationChange: (section: string, isValid: boolean) => void; + onSuccess: (message: string) => void; + onError: (message: string) => void; +} + +export function LibraryTab({ + settings, + onChange, + onValidationChange, + onSuccess, + onError, +}: LibraryTabProps) { + const { + plexLibraries, + testingPlex, + plexTestResult, + testPlexConnection, + absLibraries, + testingAbs, + absTestResult, + testABSConnection, + } = useLibrarySettings(onSuccess, onError, onValidationChange); + + const handleTestPlexConnection = () => { + testPlexConnection(settings.plex.url, settings.plex.token); + }; + + const handleTestABSConnection = () => { + testABSConnection(settings.audiobookshelf.serverUrl, settings.audiobookshelf.apiToken); + }; + + // Render appropriate section based on backend mode + if (settings.backendMode === 'plex') { + return ( + + ); + } + + if (settings.backendMode === 'audiobookshelf') { + return ( + + ); + } + + // Fallback (shouldn't happen) + return ( +
+

Invalid backend mode. Please configure your backend in setup.

+
+ ); +} diff --git a/src/app/admin/settings/tabs/LibraryTab/PlexSection.tsx b/src/app/admin/settings/tabs/LibraryTab/PlexSection.tsx new file mode 100644 index 0000000..5769270 --- /dev/null +++ b/src/app/admin/settings/tabs/LibraryTab/PlexSection.tsx @@ -0,0 +1,198 @@ +/** + * Component: Plex Library Settings Section + * Documentation: documentation/settings-pages.md + */ + +import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/Button'; +import { Settings, PlexLibrary } from '../../lib/types'; + +interface PlexSectionProps { + settings: Settings; + onChange: (settings: Settings) => void; + onValidationChange: (section: string, isValid: boolean) => void; + libraries: PlexLibrary[]; + testing: boolean; + testResult: { success: boolean; message: string } | null; + onTestConnection: () => void; +} + +export function PlexSection({ + settings, + onChange, + onValidationChange, + libraries, + testing, + testResult, + onTestConnection, +}: PlexSectionProps) { + const handleUrlChange = (url: string) => { + onChange({ + ...settings, + plex: { ...settings.plex, url }, + }); + onValidationChange('plex', false); + }; + + const handleTokenChange = (token: string) => { + onChange({ + ...settings, + plex: { ...settings.plex, token }, + }); + onValidationChange('plex', false); + }; + + const handleLibraryChange = (libraryId: string) => { + onChange({ + ...settings, + plex: { ...settings.plex, libraryId }, + }); + onValidationChange('plex', false); + }; + + const handleTriggerScanChange = (triggerScanAfterImport: boolean) => { + onChange({ + ...settings, + plex: { ...settings.plex, triggerScanAfterImport }, + }); + }; + + const handleAudibleRegionChange = (audibleRegion: string) => { + onChange({ + ...settings, + audibleRegion, + }); + }; + + return ( +
+
+

+ Plex Media Server +

+

+ Configure your Plex server connection and audiobook library. +

+
+ +
+ + handleUrlChange(e.target.value)} + placeholder="http://localhost:32400" + /> +
+ +
+ + handleTokenChange(e.target.value)} + placeholder="Enter your Plex token" + /> +

+ Find your token in Plex settings โ†’ Network โ†’ Show Advanced +

+
+ +
+ + {libraries.length > 0 ? ( + + ) : ( +
+ Test your connection to load libraries. +
+ )} +
+ +
+ +
+ + {/* Audible Region Selection */} +
+ + +

+ Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) + configuration in Plex. This ensures accurate book matching and metadata. +

+
+ +
+ + {testResult && ( +
+ {testResult.message} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/settings/tabs/LibraryTab/index.ts b/src/app/admin/settings/tabs/LibraryTab/index.ts new file mode 100644 index 0000000..4b12576 --- /dev/null +++ b/src/app/admin/settings/tabs/LibraryTab/index.ts @@ -0,0 +1,9 @@ +/** + * Component: Library Tab Exports + * Documentation: documentation/settings-pages.md + */ + +export { LibraryTab } from './LibraryTab'; +export { useLibrarySettings } from './useLibrarySettings'; +export { PlexSection } from './PlexSection'; +export { AudiobookshelfSection } from './AudiobookshelfSection'; diff --git a/src/app/admin/settings/tabs/LibraryTab/useLibrarySettings.ts b/src/app/admin/settings/tabs/LibraryTab/useLibrarySettings.ts new file mode 100644 index 0000000..68cdf74 --- /dev/null +++ b/src/app/admin/settings/tabs/LibraryTab/useLibrarySettings.ts @@ -0,0 +1,146 @@ +/** + * Component: Library Settings Hook + * Documentation: documentation/settings-pages.md + */ + +import { useState, useCallback } from 'react'; +import { fetchWithAuth } from '@/lib/utils/api'; +import { PlexLibrary, ABSLibrary } from '../../lib/types'; + +interface UseLibrarySettingsReturn { + // Plex state + plexLibraries: PlexLibrary[]; + setPlexLibraries: (libraries: PlexLibrary[]) => void; + testingPlex: boolean; + plexTestResult: { success: boolean; message: string } | null; + testPlexConnection: (url: string, token: string) => Promise; + + // ABS state + absLibraries: ABSLibrary[]; + setAbsLibraries: (libraries: ABSLibrary[]) => void; + testingAbs: boolean; + absTestResult: { success: boolean; message: string } | null; + testABSConnection: (serverUrl: string, apiToken: string) => Promise; + + // Shared state + loadingLibraries: boolean; +} + +export function useLibrarySettings( + onSuccess: (message: string) => void, + onError: (message: string) => void, + onValidationChange: (section: string, isValid: boolean) => void +): UseLibrarySettingsReturn { + // Plex state + const [plexLibraries, setPlexLibraries] = useState([]); + const [testingPlex, setTestingPlex] = useState(false); + const [plexTestResult, setPlexTestResult] = useState<{ success: boolean; message: string } | null>(null); + + // ABS state + const [absLibraries, setAbsLibraries] = useState([]); + const [testingAbs, setTestingAbs] = useState(false); + const [absTestResult, setAbsTestResult] = useState<{ success: boolean; message: string } | null>(null); + + // Shared state + const [loadingLibraries, setLoadingLibraries] = useState(false); + + const testPlexConnection = useCallback(async (url: string, token: string): Promise => { + setTestingPlex(true); + setPlexTestResult(null); + + try { + const response = await fetchWithAuth('/api/admin/settings/test-plex', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, token }), + }); + + const data = await response.json(); + + if (data.success) { + setPlexTestResult({ success: true, message: `Connected to ${data.serverName}` }); + onSuccess(`Connected to ${data.serverName}. You can now save.`); + onValidationChange('plex', true); + + // Update libraries + if (data.libraries) { + setPlexLibraries(data.libraries); + } + + return true; + } else { + const errorMsg = data.error || 'Connection failed'; + setPlexTestResult({ success: false, message: errorMsg }); + onError(errorMsg); + onValidationChange('plex', false); + return false; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; + setPlexTestResult({ success: false, message: errorMsg }); + onError(errorMsg); + onValidationChange('plex', false); + return false; + } finally { + setTestingPlex(false); + } + }, [onSuccess, onError, onValidationChange]); + + const testABSConnection = useCallback(async (serverUrl: string, apiToken: string): Promise => { + setTestingAbs(true); + setAbsTestResult(null); + + try { + const response = await fetchWithAuth('/api/setup/test-abs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverUrl, apiToken }), + }); + + const data = await response.json(); + + if (data.success) { + setAbsTestResult({ success: true, message: 'Connected to Audiobookshelf' }); + onSuccess('Connected to Audiobookshelf. You can now save.'); + onValidationChange('audiobookshelf', true); + + // Update libraries + if (data.libraries) { + setAbsLibraries(data.libraries); + } + + return true; + } else { + const errorMsg = data.error || 'Connection failed'; + setAbsTestResult({ success: false, message: errorMsg }); + onError(errorMsg); + onValidationChange('audiobookshelf', false); + return false; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to test connection'; + setAbsTestResult({ success: false, message: errorMsg }); + onError(errorMsg); + onValidationChange('audiobookshelf', false); + return false; + } finally { + setTestingAbs(false); + } + }, [onSuccess, onError, onValidationChange]); + + return { + plexLibraries, + setPlexLibraries, + testingPlex, + plexTestResult, + testPlexConnection, + + absLibraries, + setAbsLibraries, + testingAbs, + absTestResult, + testABSConnection, + + loadingLibraries, + }; +} diff --git a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx new file mode 100644 index 0000000..a43b419 --- /dev/null +++ b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx @@ -0,0 +1,146 @@ +/** + * Component: Paths Settings Tab + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { usePathsSettings } from './usePathsSettings'; +import type { PathsSettings } from '../../lib/types'; + +interface PathsTabProps { + paths: PathsSettings; + onChange: (paths: PathsSettings) => void; + onValidationChange: (isValid: boolean) => void; +} + +export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) { + const { testing, testResult, updatePath, testPaths } = usePathsSettings({ + paths, + onChange, + onValidationChange, + }); + + return ( +
+
+

+ Directory Paths +

+

+ Configure download and media directory paths. +

+
+ + {/* Download Directory */} +
+ + updatePath('downloadDir', e.target.value)} + placeholder="/downloads" + className="font-mono" + /> +

+ Temporary location for torrent downloads (kept for seeding) +

+
+ + {/* Media Directory */} +
+ + updatePath('mediaDir', e.target.value)} + placeholder="/media/audiobooks" + className="font-mono" + /> +

+ Final location for organized audiobook library (Your backend scans this directory) +

+
+ + {/* Metadata Tagging Toggle */} +
+
+ updatePath('metadataTaggingEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Automatically write correct title, author, and narrator metadata to m4b and mp3 files + during file organization. This significantly improves Plex matching accuracy for audiobooks + with missing or incorrect metadata. +

+
+
+
+ + {/* Chapter Merging Toggle */} +
+
+ updatePath('chapterMergingEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter + markers. Improves playback experience and library organization. +

+
+
+
+ + {/* Test Paths Button */} +
+ + {testResult && ( +
+ {testResult.message} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/settings/tabs/PathsTab/index.ts b/src/app/admin/settings/tabs/PathsTab/index.ts new file mode 100644 index 0000000..48e21d9 --- /dev/null +++ b/src/app/admin/settings/tabs/PathsTab/index.ts @@ -0,0 +1,2 @@ +export { PathsTab } from './PathsTab'; +export { usePathsSettings } from './usePathsSettings'; diff --git a/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts new file mode 100644 index 0000000..6d5f206 --- /dev/null +++ b/src/app/admin/settings/tabs/PathsTab/usePathsSettings.ts @@ -0,0 +1,84 @@ +/** + * Component: Paths Settings Tab - Custom Hook + * Documentation: documentation/settings-pages.md + */ + +'use client'; + +import { useState } from 'react'; +import type { PathsSettings, TestResult } from '../../lib/types'; + +interface UsePathsSettingsProps { + paths: PathsSettings; + onChange: (paths: PathsSettings) => void; + onValidationChange: (isValid: boolean) => void; +} + +export function usePathsSettings({ paths, onChange, onValidationChange }: UsePathsSettingsProps) { + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + /** + * Update a single path field + */ + const updatePath = (field: keyof PathsSettings, value: string | boolean) => { + onChange({ ...paths, [field]: value }); + onValidationChange(false); + }; + + /** + * Test if paths are valid and writable + */ + const testPaths = async () => { + setTesting(true); + setTestResult(null); + + try { + const response = await fetch('/api/setup/test-paths', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + downloadDir: paths.downloadDir, + mediaDir: paths.mediaDir, + }), + }); + + const data = await response.json(); + + if (data.success) { + const result: TestResult = { + success: true, + message: 'All paths are valid and writable' + }; + setTestResult(result); + onValidationChange(true); + return result; + } else { + const result: TestResult = { + success: false, + message: data.error || 'Path validation failed' + }; + setTestResult(result); + onValidationChange(false); + return result; + } + } catch (error) { + const result: TestResult = { + success: false, + message: error instanceof Error ? error.message : 'Failed to test paths' + }; + setTestResult(result); + onValidationChange(false); + return result; + } finally { + setTesting(false); + } + }; + + return { + testing, + testResult, + updatePath, + testPaths, + }; +} diff --git a/src/app/api/bookdate/test-connection/route.ts b/src/app/api/bookdate/test-connection/route.ts index 3d19993..89b49ca 100644 --- a/src/app/api/bookdate/test-connection/route.ts +++ b/src/app/api/bookdate/test-connection/route.ts @@ -191,9 +191,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) { if (!response.ok) { const errorText = await response.text(); logger.error('Custom provider connection error', { error: errorText }); + // Return 400 (not the external service's status) to prevent triggering logout on 401 return NextResponse.json( { error: `Failed to connect to custom provider: ${response.status} ${errorText}` }, - { status: response.status } + { status: 400 } ); } @@ -382,9 +383,10 @@ async function unauthenticatedHandler(req: NextRequest) { if (!response.ok) { const errorText = await response.text(); logger.error('Custom provider connection error', { error: errorText }); + // Return 400 (not the external service's status) to prevent triggering logout on 401 return NextResponse.json( { error: `Failed to connect to custom provider: ${response.status} ${errorText}` }, - { status: response.status } + { status: 400 } ); } diff --git a/src/lib/services/auth/OIDCAuthProvider.ts b/src/lib/services/auth/OIDCAuthProvider.ts index 8d572db..e62a86b 100644 --- a/src/lib/services/auth/OIDCAuthProvider.ts +++ b/src/lib/services/auth/OIDCAuthProvider.ts @@ -82,6 +82,9 @@ export class OIDCAuthProvider implements IAuthProvider { async initiateLogin(): Promise { try { const client = await this.getClient(); + // Clean up expired states first + this.cleanupExpiredStates(); + const state = generators.state(); const nonce = generators.nonce(); const codeVerifier = generators.codeVerifier(); @@ -95,9 +98,6 @@ export class OIDCAuthProvider implements IAuthProvider { timestamp: Date.now(), }); - // Clean up expired states - this.cleanupExpiredStates(); - // Generate authorization URL const redirectUrl = client.authorizationUrl({ scope: 'openid profile email groups', diff --git a/tests/api/admin-backend-mode.routes.test.ts b/tests/api/admin-backend-mode.routes.test.ts new file mode 100644 index 0000000..340af0e --- /dev/null +++ b/tests/api/admin-backend-mode.routes.test.ts @@ -0,0 +1,62 @@ +/** + * Component: Admin Backend Mode API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const configServiceMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + setMany: vi.fn(), +})); +const clearLibraryServiceCacheMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + ConfigurationService: class { + getBackendMode = configServiceMock.getBackendMode; + setMany = configServiceMock.setMany; + }, +})); + +vi.mock('@/lib/services/library', () => ({ + clearLibraryServiceCache: clearLibraryServiceCacheMock, +})); + +describe('Admin backend mode route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns backend mode', async () => { + configServiceMock.getBackendMode.mockResolvedValue('plex'); + + const { GET } = await import('@/app/api/admin/backend-mode/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.backendMode).toBe('plex'); + }); + + it('updates backend mode and clears cache', async () => { + const { PUT } = await import('@/app/api/admin/backend-mode/route'); + const response = await PUT({ json: vi.fn().mockResolvedValue({ mode: 'audiobookshelf' }) } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(clearLibraryServiceCacheMock).toHaveBeenCalled(); + }); +}); + + diff --git a/tests/api/admin-bookdate.routes.test.ts b/tests/api/admin-bookdate.routes.test.ts new file mode 100644 index 0000000..89ea21a --- /dev/null +++ b/tests/api/admin-bookdate.routes.test.ts @@ -0,0 +1,49 @@ +/** + * Component: Admin BookDate API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +describe('Admin BookDate toggle route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'admin-1', role: 'admin' }, + json: vi.fn(), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('toggles BookDate enabled state', async () => { + authRequest.json.mockResolvedValue({ isEnabled: true }); + prismaMock.bookDateConfig.updateMany.mockResolvedValue({ count: 1 }); + + const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route'); + const response = await PATCH({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.isEnabled).toBe(true); + expect(prismaMock.bookDateConfig.updateMany).toHaveBeenCalledWith({ data: { isEnabled: true } }); + }); +}); + + diff --git a/tests/api/admin-downloads.routes.test.ts b/tests/api/admin-downloads.routes.test.ts new file mode 100644 index 0000000..131e645 --- /dev/null +++ b/tests/api/admin-downloads.routes.test.ts @@ -0,0 +1,70 @@ +/** + * Component: Admin Downloads API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); +const qbittorrentMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + getQBittorrentService: async () => qbittorrentMock, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + getSABnzbdService: async () => ({ getNZB: vi.fn() }), +})); + +describe('Admin downloads route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns formatted active downloads', async () => { + prismaMock.request.findMany.mockResolvedValueOnce([ + { + id: 'req-1', + status: 'downloading', + progress: 50, + updatedAt: new Date(), + audiobook: { title: 'Title', author: 'Author' }, + user: { plexUsername: 'user' }, + downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }], + }, + ]); + configServiceMock.get.mockResolvedValueOnce('qbittorrent'); + qbittorrentMock.getTorrent.mockResolvedValueOnce({ dlspeed: 123, eta: 60 }); + + const { GET } = await import('@/app/api/admin/downloads/active/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.downloads[0].speed).toBe(123); + expect(payload.downloads[0].torrentName).toBe('Torrent'); + }); +}); + + diff --git a/tests/api/admin-job-status.routes.test.ts b/tests/api/admin-job-status.routes.test.ts new file mode 100644 index 0000000..17365b4 --- /dev/null +++ b/tests/api/admin-job-status.routes.test.ts @@ -0,0 +1,63 @@ +/** + * Component: Admin Job Status API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const verifyAccessTokenMock = vi.hoisted(() => vi.fn()); +const jobQueueMock = vi.hoisted(() => ({ getJob: vi.fn() })); + +vi.mock('@/lib/utils/jwt', () => ({ + verifyAccessToken: verifyAccessTokenMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +const makeRequest = (token?: string) => ({ + headers: { + get: (key: string) => (key.toLowerCase() === 'authorization' ? token : null), + }, +}); + +describe('Admin job status route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects missing authorization', async () => { + const { GET } = await import('@/app/api/admin/job-status/[id]/route'); + const response = await GET(makeRequest() as any, { params: Promise.resolve({ id: '1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns job status for admin token', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'admin' }); + jobQueueMock.getJob.mockResolvedValue({ + id: '1', + type: 'search', + status: 'completed', + createdAt: new Date(), + startedAt: null, + completedAt: null, + result: null, + errorMessage: null, + attempts: 1, + maxAttempts: 3, + }); + + const { GET } = await import('@/app/api/admin/job-status/[id]/route'); + const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: '1' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.job.status).toBe('completed'); + }); +}); + + diff --git a/tests/api/admin-jobs.routes.test.ts b/tests/api/admin-jobs.routes.test.ts new file mode 100644 index 0000000..6ff01a0 --- /dev/null +++ b/tests/api/admin-jobs.routes.test.ts @@ -0,0 +1,89 @@ +/** + * Component: Admin Jobs API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const verifyAccessTokenMock = vi.hoisted(() => vi.fn()); +const schedulerMock = vi.hoisted(() => ({ + getScheduledJobs: vi.fn(), + createScheduledJob: vi.fn(), + updateScheduledJob: vi.fn(), + deleteScheduledJob: vi.fn(), + triggerJobNow: vi.fn(), +})); + +vi.mock('@/lib/utils/jwt', () => ({ + verifyAccessToken: verifyAccessTokenMock, +})); + +vi.mock('@/lib/services/scheduler.service', () => ({ + getSchedulerService: () => schedulerMock, +})); + +const makeRequest = (token?: string, body?: any) => ({ + headers: { + get: (key: string) => (key.toLowerCase() === 'authorization' ? token : null), + }, + json: vi.fn().mockResolvedValue(body || {}), +}); + +describe('Admin jobs routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + verifyAccessTokenMock.mockReturnValue({ role: 'admin' }); + }); + + it('lists scheduled jobs', async () => { + schedulerMock.getScheduledJobs.mockResolvedValue([{ id: 'job-1' }]); + const { GET } = await import('@/app/api/admin/jobs/route'); + + const response = await GET(makeRequest('Bearer token') as any); + const payload = await response.json(); + + expect(payload.jobs).toHaveLength(1); + }); + + it('creates a scheduled job', async () => { + schedulerMock.createScheduledJob.mockResolvedValue({ id: 'job-2' }); + const { POST } = await import('@/app/api/admin/jobs/route'); + + const response = await POST(makeRequest('Bearer token', { name: 'Job', type: 'type', schedule: '* * * * *' }) as any); + const payload = await response.json(); + + expect(payload.job.id).toBe('job-2'); + }); + + it('updates a scheduled job', async () => { + schedulerMock.updateScheduledJob.mockResolvedValue({ id: 'job-3' }); + const { PUT } = await import('@/app/api/admin/jobs/[id]/route'); + + const response = await PUT(makeRequest('Bearer token', { name: 'Job' }) as any, { params: Promise.resolve({ id: 'job-3' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('deletes a scheduled job', async () => { + schedulerMock.deleteScheduledJob.mockResolvedValue(undefined); + const { DELETE } = await import('@/app/api/admin/jobs/[id]/route'); + + const response = await DELETE(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-4' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('triggers a scheduled job', async () => { + schedulerMock.triggerJobNow.mockResolvedValue('job-5'); + const { POST } = await import('@/app/api/admin/jobs/[id]/trigger/route'); + + const response = await POST(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-5' }) }); + const payload = await response.json(); + + expect(payload.jobId).toBe('job-5'); + }); +}); + + diff --git a/tests/api/admin-logs.routes.test.ts b/tests/api/admin-logs.routes.test.ts new file mode 100644 index 0000000..3455a01 --- /dev/null +++ b/tests/api/admin-logs.routes.test.ts @@ -0,0 +1,45 @@ +/** + * Component: Admin Logs API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +describe('Admin logs route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns paginated logs', async () => { + prismaMock.job.findMany.mockResolvedValueOnce([{ id: 'job-1' }]); + prismaMock.job.count.mockResolvedValueOnce(1); + + const { GET } = await import('@/app/api/admin/logs/route'); + const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=10' } as any); + const payload = await response.json(); + + expect(payload.logs).toHaveLength(1); + expect(payload.pagination.total).toBe(1); + }); +}); + + diff --git a/tests/api/admin-metrics.routes.test.ts b/tests/api/admin-metrics.routes.test.ts new file mode 100644 index 0000000..fa0b31c --- /dev/null +++ b/tests/api/admin-metrics.routes.test.ts @@ -0,0 +1,51 @@ +/** + * Component: Admin Metrics API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +describe('Admin metrics route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns metrics and system health', async () => { + prismaMock.request.count + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(5) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0); + prismaMock.user.count.mockResolvedValueOnce(3); + prismaMock.$queryRaw.mockResolvedValueOnce(1); + + const { GET } = await import('@/app/api/admin/metrics/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.totalRequests).toBe(10); + expect(payload.systemHealth.status).toBe('healthy'); + }); +}); + + diff --git a/tests/api/admin-plex.routes.test.ts b/tests/api/admin-plex.routes.test.ts new file mode 100644 index 0000000..f8a0474 --- /dev/null +++ b/tests/api/admin-plex.routes.test.ts @@ -0,0 +1,43 @@ +/** + * Component: Admin Plex API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const scanPlexMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/processors/scan-plex.processor', () => ({ + processScanPlex: scanPlexMock, +})); + +describe('Admin Plex scan route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('triggers a Plex scan', async () => { + scanPlexMock.mockResolvedValue({ scanned: 10 }); + + const { POST } = await import('@/app/api/admin/plex/scan/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(scanPlexMock).toHaveBeenCalled(); + }); +}); + + diff --git a/tests/api/admin-requests.routes.test.ts b/tests/api/admin-requests.routes.test.ts new file mode 100644 index 0000000..13fef32 --- /dev/null +++ b/tests/api/admin-requests.routes.test.ts @@ -0,0 +1,78 @@ +/** + * Component: Admin Requests API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const deleteRequestMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/services/request-delete.service', () => ({ + deleteRequest: deleteRequestMock, +})); + +describe('Admin requests routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns recent requests', async () => { + prismaMock.request.findMany.mockResolvedValueOnce([ + { + id: 'req-1', + status: 'pending', + createdAt: new Date(), + completedAt: null, + errorMessage: null, + audiobook: { title: 'Title', author: 'Author' }, + user: { plexUsername: 'user' }, + downloadHistory: [{ torrentUrl: 'http://torrent' }], + }, + ]); + + const { GET } = await import('@/app/api/admin/requests/recent/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.requests).toHaveLength(1); + expect(payload.requests[0].torrentUrl).toBe('http://torrent'); + }); + + it('soft deletes a request via delete service', async () => { + deleteRequestMock.mockResolvedValueOnce({ + success: true, + message: 'Deleted', + filesDeleted: 1, + torrentsRemoved: 0, + torrentsKeptSeeding: 0, + torrentsKeptUnlimited: 0, + }); + + const { DELETE } = await import('@/app/api/admin/requests/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(deleteRequestMock).toHaveBeenCalledWith('req-1', 'admin-1'); + }); +}); + + diff --git a/tests/api/admin-settings-core.routes.test.ts b/tests/api/admin-settings-core.routes.test.ts new file mode 100644 index 0000000..b74b102 --- /dev/null +++ b/tests/api/admin-settings-core.routes.test.ts @@ -0,0 +1,225 @@ +/** + * Component: Admin Settings Core API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const configServiceMock = vi.hoisted(() => ({ + setMany: vi.fn(), + clearCache: vi.fn(), +})); +const audibleServiceMock = vi.hoisted(() => ({ + forceReinitialize: vi.fn(), +})); +const jobQueueMock = vi.hoisted(() => ({ + addAudibleRefreshJob: vi.fn(), +})); +const plexServiceMock = vi.hoisted(() => ({ + testConnection: vi.fn(), +})); +const pathMapperMock = vi.hoisted(() => ({ + validate: vi.fn(), +})); +const invalidateQbMock = vi.hoisted(() => vi.fn()); +const invalidateSabMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => audibleServiceMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => plexServiceMock, +})); + +vi.mock('@/lib/utils/path-mapper', () => ({ + PathMapper: pathMapperMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + invalidateQBittorrentService: invalidateQbMock, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + invalidateSABnzbdService: invalidateSabMock, +})); + +describe('Admin settings core routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns settings payload', async () => { + prismaMock.configuration.findMany.mockResolvedValueOnce([ + { key: 'plex_url', value: 'http://plex' }, + { key: 'plex_token', value: 'token' }, + { key: 'system.backend_mode', value: 'plex' }, + ]); + prismaMock.user.count.mockResolvedValueOnce(0); + + const { GET } = await import('@/app/api/admin/settings/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.plex.url).toBe('http://plex'); + expect(payload.backendMode).toBe('plex'); + }); + + it('updates Plex settings', async () => { + plexServiceMock.testConnection.mockResolvedValue({ success: true, info: { machineIdentifier: 'machine' } }); + const request = { + json: vi.fn().mockResolvedValue({ + url: 'http://plex', + token: 'token', + libraryId: 'lib', + triggerScanAfterImport: true, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/plex/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(prismaMock.configuration.upsert).toHaveBeenCalled(); + }); + + it('updates download client settings', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: 'pass', + remotePathMappingEnabled: false, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/download-client/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(invalidateQbMock).toHaveBeenCalled(); + }); + + it('updates paths settings', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + downloadDir: '/downloads', + mediaDir: '/media', + metadataTaggingEnabled: true, + chapterMergingEnabled: false, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/paths/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(invalidateQbMock).toHaveBeenCalled(); + }); + + it('updates Prowlarr settings', async () => { + const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) }; + + const { PUT } = await import('@/app/api/admin/settings/prowlarr/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('updates registration settings', async () => { + const request = { json: vi.fn().mockResolvedValue({ enabled: true, requireAdminApproval: false }) }; + + const { PUT } = await import('@/app/api/admin/settings/registration/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(configServiceMock.setMany).toHaveBeenCalled(); + }); + + it('updates OIDC settings', async () => { + const request = { json: vi.fn().mockResolvedValue({ enabled: true, providerName: 'OIDC', clientSecret: 'secret' }) }; + + const { PUT } = await import('@/app/api/admin/settings/oidc/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(configServiceMock.setMany).toHaveBeenCalled(); + }); + + it('updates ebook settings', async () => { + const request = { + json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.li' }), + }; + + const { PUT } = await import('@/app/api/admin/settings/ebook/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(configServiceMock.setMany).toHaveBeenCalled(); + }); + + it('updates Audible region and triggers refresh', async () => { + const request = { json: vi.fn().mockResolvedValue({ region: 'us' }) }; + + const { PUT } = await import('@/app/api/admin/settings/audible/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addAudibleRefreshJob).toHaveBeenCalled(); + }); + + it('updates Audiobookshelf settings', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + serverUrl: 'http://abs', + apiToken: 'token', + libraryId: 'lib', + triggerScanAfterImport: true, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/audiobookshelf/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(configServiceMock.setMany).toHaveBeenCalled(); + }); +}); + + diff --git a/tests/api/admin-settings-libraries.routes.test.ts b/tests/api/admin-settings-libraries.routes.test.ts new file mode 100644 index 0000000..6d3af17 --- /dev/null +++ b/tests/api/admin-settings-libraries.routes.test.ts @@ -0,0 +1,77 @@ +/** + * Component: Admin Settings Libraries API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const plexServiceMock = vi.hoisted(() => ({ getLibraries: vi.fn() })); +const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: async () => plexServiceMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('Admin settings libraries routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns Plex libraries', async () => { + configServiceMock.get + .mockResolvedValueOnce('http://plex') + .mockResolvedValueOnce('token'); + plexServiceMock.getLibraries.mockResolvedValueOnce([ + { key: '1', title: 'Audiobooks', type: 'artist' }, + { key: '2', title: 'Movies', type: 'movie' }, + ]); + + const { GET } = await import('@/app/api/admin/settings/plex/libraries/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.libraries).toHaveLength(1); + }); + + it('returns Audiobookshelf libraries', async () => { + configServiceMock.get + .mockResolvedValueOnce('http://abs') + .mockResolvedValueOnce('token'); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + libraries: [ + { id: '1', name: 'Books', mediaType: 'book', stats: { totalItems: 10 } }, + { id: '2', name: 'Music', mediaType: 'music' }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const { GET } = await import('@/app/api/admin/settings/audiobookshelf/libraries/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.libraries).toHaveLength(1); + expect(payload.libraries[0].id).toBe('1'); + }); +}); + + diff --git a/tests/api/admin-settings-prowlarr-indexers.routes.test.ts b/tests/api/admin-settings-prowlarr-indexers.routes.test.ts new file mode 100644 index 0000000..9b0fd56 --- /dev/null +++ b/tests/api/admin-settings-prowlarr-indexers.routes.test.ts @@ -0,0 +1,69 @@ +/** + * Component: Admin Prowlarr Indexers API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const prowlarrMock = vi.hoisted(() => ({ + getIndexers: vi.fn(), +})); +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), + setMany: vi.fn(), +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + getProwlarrService: async () => prowlarrMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('Admin Prowlarr indexers route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns indexers with saved config', async () => { + prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent' }]); + configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 5, seedingTimeMinutes: 10 }])); + configServiceMock.get.mockResolvedValueOnce('[]'); + + const { GET } = await import('@/app/api/admin/settings/prowlarr/indexers/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.indexers[0].enabled).toBe(true); + }); + + it('saves indexer configuration', async () => { + authRequest.json.mockResolvedValue({ + indexers: [{ id: 1, name: 'Indexer', enabled: true, priority: 10, seedingTimeMinutes: 0 }], + flagConfigs: [], + }); + + const { PUT } = await import('@/app/api/admin/settings/prowlarr/indexers/route'); + const response = await PUT({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(configServiceMock.setMany).toHaveBeenCalled(); + }); +}); + + diff --git a/tests/api/admin-settings-tests.routes.test.ts b/tests/api/admin-settings-tests.routes.test.ts new file mode 100644 index 0000000..ea9d448 --- /dev/null +++ b/tests/api/admin-settings-tests.routes.test.ts @@ -0,0 +1,257 @@ +/** + * Component: Admin Settings Test API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const plexServiceMock = vi.hoisted(() => ({ + testConnection: vi.fn(), + getLibraries: vi.fn(), +})); +const prowlarrMock = vi.hoisted(() => ({ + getIndexers: vi.fn(), +})); +const qbtMock = vi.hoisted(() => ({ + testConnectionWithCredentials: vi.fn(), +})); +const sabnzbdMock = vi.hoisted(() => ({ + testConnection: vi.fn(), +})); +const testFlareSolverrMock = vi.hoisted(() => vi.fn()); +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + constants: { R_OK: 4 }, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => plexServiceMock, +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + ProwlarrService: class { + constructor() {} + getIndexers = prowlarrMock.getIndexers; + }, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + QBittorrentService: { + testConnectionWithCredentials: qbtMock.testConnectionWithCredentials, + }, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + SABnzbdService: class { + constructor() {} + testConnection = sabnzbdMock.testConnection; + }, +})); + +vi.mock('@/lib/services/ebook-scraper', () => ({ + testFlareSolverrConnection: testFlareSolverrMock, +})); + +vi.mock('fs/promises', () => ({ + default: fsMock, + ...fsMock, +})); + +describe('Admin settings test routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + fsMock.access.mockResolvedValue(undefined); + }); + + it('tests Plex connection with stored token', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'token' }); + plexServiceMock.testConnection.mockResolvedValueOnce({ success: true, info: { platform: 'Plex', version: '1.0' } }); + plexServiceMock.getLibraries.mockResolvedValueOnce([{ id: '1', title: 'Books', type: 'book' }]); + + const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: '********' }) }; + const { POST } = await import('@/app/api/admin/settings/test-plex/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('tests Prowlarr connection', async () => { + prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true }]); + const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) }; + + const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('tests download client connection', async () => { + qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0'); + const request = { + json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt', username: 'user', password: 'pass' }), + }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.version).toBe('4.0.0'); + }); + + it('validates required fields for download client testing', async () => { + const request = { json: vi.fn().mockResolvedValue({ url: 'http://qbt' }) }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Type and URL are required/); + }); + + it('rejects invalid download client types', async () => { + const request = { json: vi.fn().mockResolvedValue({ type: 'invalid', url: 'http://qbt' }) }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid client type/); + }); + + it('uses stored password when masked password is provided', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'stored-pass' }); + qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.1.0'); + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: '\u2022\u2022\u2022\u2022', + }), + }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(qbtMock.testConnectionWithCredentials).toHaveBeenCalledWith( + 'http://qbt', + 'user', + 'stored-pass', + false + ); + }); + + it('returns error when masked password is missing in storage', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce(null); + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: '\u2022\u2022\u2022\u2022', + }), + }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/No stored password/); + }); + + it('returns error when SABnzbd connection fails', async () => { + sabnzbdMock.testConnection.mockResolvedValueOnce({ success: false, error: 'bad key' }); + const request = { + json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab', password: 'key' }), + }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/bad key/); + }); + + it('requires path mapping values when enabled', async () => { + qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0'); + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: 'pass', + remotePathMappingEnabled: true, + }), + }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Remote path and local path are required/); + }); + + it('rejects inaccessible local path when mapping is enabled', async () => { + qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0'); + fsMock.access.mockRejectedValueOnce(new Error('missing')); + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: 'pass', + remotePathMappingEnabled: true, + remotePath: '/remote', + localPath: '/local', + }), + }; + + const { POST } = await import('@/app/api/admin/settings/test-download-client/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/not accessible/); + }); + + it('tests FlareSolverr connection', async () => { + testFlareSolverrMock.mockResolvedValueOnce({ success: true }); + const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare' }) }; + + const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); +}); + + diff --git a/tests/api/admin-users.routes.test.ts b/tests/api/admin-users.routes.test.ts new file mode 100644 index 0000000..9a81c01 --- /dev/null +++ b/tests/api/admin-users.routes.test.ts @@ -0,0 +1,104 @@ +/** + * Component: Admin Users API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +describe('Admin users routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { sub: 'admin-1', role: 'admin' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + }); + + it('returns users list', async () => { + prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u1' }]); + + const { GET } = await import('@/app/api/admin/users/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.users).toHaveLength(1); + }); + + it('returns pending users list', async () => { + prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u2' }]); + + const { GET } = await import('@/app/api/admin/users/pending/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.users).toHaveLength(1); + }); + + it('updates a user role', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + isSetupAdmin: false, + authProvider: 'local', + plexUsername: 'user', + deletedAt: null, + }); + prismaMock.user.update.mockResolvedValueOnce({ id: 'u3', plexUsername: 'user', role: 'admin' }); + const request = { json: vi.fn().mockResolvedValue({ role: 'admin' }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'u3' }) }); + const payload = await response.json(); + + expect(payload.user.role).toBe('admin'); + }); + + it('soft deletes a local user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'u4', + plexUsername: 'user', + isSetupAdmin: false, + authProvider: 'local', + deletedAt: null, + _count: { requests: 1 }, + }); + prismaMock.user.update.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/admin/users/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u4' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('approves a pending user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'u5', + plexUsername: 'user', + registrationStatus: 'pending_approval', + }); + prismaMock.user.update.mockResolvedValueOnce({}); + const request = { json: vi.fn().mockResolvedValue({ approve: true }) }; + + const { POST } = await import('@/app/api/admin/users/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'u5' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); +}); + + diff --git a/tests/api/audiobooks-browse.routes.test.ts b/tests/api/audiobooks-browse.routes.test.ts new file mode 100644 index 0000000..2456610 --- /dev/null +++ b/tests/api/audiobooks-browse.routes.test.ts @@ -0,0 +1,134 @@ +/** + * Component: Audiobooks Browse API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const audibleServiceMock = vi.hoisted(() => ({ + search: vi.fn(), + getAudiobookDetails: vi.fn(), +})); +const enrichMock = vi.hoisted(() => vi.fn()); +const currentUserMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => audibleServiceMock, +})); + +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + enrichAudiobooksWithMatches: enrichMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + getCurrentUser: currentUserMock, +})); + +describe('Audiobooks browse routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + enrichMock.mockResolvedValue([]); + currentUserMock.mockReturnValue(null); + }); + + it('searches Audible and enriches results', async () => { + audibleServiceMock.search.mockResolvedValue({ + query: 'query', + results: [{ asin: 'ASIN', title: 'Title', author: 'Author' }], + totalResults: 1, + page: 1, + hasMore: false, + }); + currentUserMock.mockReturnValue({ sub: 'user-1' }); + enrichMock.mockResolvedValue([{ asin: 'ASIN', available: false }]); + + const { GET } = await import('@/app/api/audiobooks/search/route'); + const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/search?q=query') } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(enrichMock).toHaveBeenCalledWith([{ asin: 'ASIN', title: 'Title', author: 'Author' }], 'user-1'); + }); + + it('returns 400 for invalid popular pagination', async () => { + const { GET } = await import('@/app/api/audiobooks/popular/route'); + + const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/popular?page=0') } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns popular audiobooks with cached cover URLs', async () => { + prismaMock.audibleCache.findMany.mockResolvedValueOnce([ + { + asin: 'ASIN', + title: 'Title', + author: 'Author', + narrator: null, + description: null, + coverArtUrl: 'http://image', + cachedCoverPath: '/tmp/cache/asin.jpg', + durationMinutes: 90, + releaseDate: new Date('2024-01-01'), + rating: 4.5, + genres: [], + lastSyncedAt: new Date(), + }, + ]); + prismaMock.audibleCache.count.mockResolvedValueOnce(1); + enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]); + + const { GET } = await import('@/app/api/audiobooks/popular/route'); + const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/popular?page=1&limit=1') } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.audiobooks[0].coverArtUrl).toBe('/api/cache/thumbnails/asin.jpg'); + }); + + it('returns new release audiobooks', async () => { + prismaMock.audibleCache.findMany.mockResolvedValueOnce([]); + prismaMock.audibleCache.count.mockResolvedValueOnce(0); + + const { GET } = await import('@/app/api/audiobooks/new-releases/route'); + const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.count).toBe(0); + }); + + it('returns audiobook details when ASIN is valid', async () => { + audibleServiceMock.getAudiobookDetails.mockResolvedValue({ asin: 'ASIN123456', title: 'Title' }); + const { GET } = await import('@/app/api/audiobooks/[asin]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ asin: 'ASIN123456' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.audiobook.asin).toBe('ASIN123456'); + }); + + it('returns cached covers for login', async () => { + prismaMock.audibleCache.findMany.mockResolvedValueOnce([ + { asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null }, + ]); + const { GET } = await import('@/app/api/audiobooks/covers/route'); + + const response = await GET(); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg'); + }); +}); + + diff --git a/tests/api/audiobooks-request-torrent.routes.test.ts b/tests/api/audiobooks-request-torrent.routes.test.ts new file mode 100644 index 0000000..cbf0dfd --- /dev/null +++ b/tests/api/audiobooks-request-torrent.routes.test.ts @@ -0,0 +1,94 @@ +/** + * Component: Request With Torrent API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ + addDownloadJob: vi.fn(), +})); +const findPlexMatchMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: findPlexMatchMock, +})); + +describe('Request with torrent route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn(), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 409 when audiobook is already being processed', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-1', + status: 'downloaded', + userId: 'user-2', + user: { plexUsername: 'other' }, + }); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('BeingProcessed'); + }); + + it('creates request and queues download job', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); + prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.request.create.mockResolvedValueOnce({ + id: 'req-2', + audiobook: { id: 'ab-1', title: 'Title', author: 'Author' }, + user: { id: 'user-1', plexUsername: 'user' }, + }); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith('req-2', { + id: 'ab-1', + title: 'Title', + author: 'Author', + }, expect.objectContaining({ guid: 'guid' })); + }); +}); + + diff --git a/tests/api/audiobooks-search.routes.test.ts b/tests/api/audiobooks-search.routes.test.ts new file mode 100644 index 0000000..53855bc --- /dev/null +++ b/tests/api/audiobooks-search.routes.test.ts @@ -0,0 +1,96 @@ +/** + * Component: Audiobooks Search Torrents API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +let authRequest: any; + +const requireAuthMock = vi.hoisted(() => vi.fn()); +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); +const prowlarrMock = vi.hoisted(() => ({ + search: vi.fn(), +})); +const rankTorrentsMock = vi.hoisted(() => vi.fn()); +const groupIndexersMock = vi.hoisted(() => vi.fn()); +const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group')); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + getProwlarrService: async () => prowlarrMock, +})); + +vi.mock('@/lib/utils/ranking-algorithm', () => ({ + rankTorrents: rankTorrentsMock, +})); + +vi.mock('@/lib/utils/indexer-grouping', () => ({ + groupIndexersByCategories: groupIndexersMock, + getGroupDescription: groupDescriptionMock, +})); + +describe('Audiobooks search torrents route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + json: vi.fn(), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns error when no indexers are configured', async () => { + authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' }); + configServiceMock.get.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/audiobooks/search-torrents/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ConfigError'); + }); + + it('returns ranked results with rank order', async () => { + authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' }); + configServiceMock.get + .mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 10 }])) + .mockResolvedValueOnce(null); + + groupIndexersMock.mockReturnValue([{ categories: [1], indexerIds: [1] }]); + prowlarrMock.search.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]); + rankTorrentsMock.mockReturnValue([ + { + title: 'Result', + size: 100, + indexer: 'Indexer', + indexerId: 1, + score: 50, + breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] }, + bonusPoints: 0, + bonusModifiers: [], + finalScore: 50, + }, + ]); + + const { POST } = await import('@/app/api/audiobooks/search-torrents/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.results[0].rank).toBe(1); + expect(rankTorrentsMock).toHaveBeenCalled(); + }); +}); + + diff --git a/tests/api/auth-admin-login.routes.test.ts b/tests/api/auth-admin-login.routes.test.ts new file mode 100644 index 0000000..18f7850 --- /dev/null +++ b/tests/api/auth-admin-login.routes.test.ts @@ -0,0 +1,148 @@ +/** + * Component: Admin Login API Route Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const bcryptMock = { + compare: vi.fn(), +}; +const encryptionMock = { + decrypt: vi.fn(), +}; +const tokenMock = { + generateAccessToken: vi.fn(() => 'access-token'), + generateRefreshToken: vi.fn(() => 'refresh-token'), +}; + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('bcrypt', () => ({ + default: bcryptMock, + ...bcryptMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + generateAccessToken: tokenMock.generateAccessToken, + generateRefreshToken: tokenMock.generateRefreshToken, +})); + +const makeRequest = (body: Record) => ({ + json: vi.fn().mockResolvedValue(body), +}); + +describe('Admin login route', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DISABLE_LOCAL_LOGIN; + }); + + it('blocks local login when disabled', async () => { + process.env.DISABLE_LOCAL_LOGIN = 'true'; + const { POST } = await import('@/app/api/auth/admin/login/route'); + + const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Local login is disabled'); + }); + + it('rejects missing credentials', async () => { + const { POST } = await import('@/app/api/auth/admin/login/route'); + + const response = await POST(makeRequest({ username: 'admin' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('rejects unknown user', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + const { POST } = await import('@/app/api/auth/admin/login/route'); + + const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('AuthenticationError'); + }); + + it('rejects invalid password', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + plexId: 'local-admin', + plexUsername: 'admin', + plexEmail: null, + role: 'admin', + avatarUrl: null, + authToken: 'enc-hash', + }); + encryptionMock.decrypt.mockReturnValue('hash'); + bcryptMock.compare.mockResolvedValue(false); + const { POST } = await import('@/app/api/auth/admin/login/route'); + + const response = await POST(makeRequest({ username: 'admin', password: 'wrong' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('AuthenticationError'); + }); + + it('rejects when password verification throws', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-2', + plexId: 'local-admin', + plexUsername: 'admin', + plexEmail: null, + role: 'admin', + avatarUrl: null, + authToken: 'enc-hash', + }); + encryptionMock.decrypt.mockImplementation(() => { + throw new Error('decrypt failed'); + }); + const { POST } = await import('@/app/api/auth/admin/login/route'); + + const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('AuthenticationError'); + }); + + it('returns tokens for valid credentials', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-3', + plexId: 'local-admin', + plexUsername: 'admin', + plexEmail: 'admin@example.com', + role: 'admin', + avatarUrl: null, + authToken: 'enc-hash', + }); + prismaMock.user.update.mockResolvedValue({}); + encryptionMock.decrypt.mockReturnValue('hash'); + bcryptMock.compare.mockResolvedValue(true); + const { POST } = await import('@/app/api/auth/admin/login/route'); + + const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.accessToken).toBe('access-token'); + expect(payload.refreshToken).toBe('refresh-token'); + expect(prismaMock.user.update).toHaveBeenCalled(); + }); +}); diff --git a/tests/api/auth-change-password.routes.test.ts b/tests/api/auth-change-password.routes.test.ts new file mode 100644 index 0000000..a0954fc --- /dev/null +++ b/tests/api/auth-change-password.routes.test.ts @@ -0,0 +1,161 @@ +/** + * Component: Change Password API Route Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const bcryptMock = { + compare: vi.fn(), + hash: vi.fn(), +}; +const encryptionMock = { + decrypt: vi.fn(), + encrypt: vi.fn(), +}; +const requireAuthMock = vi.fn(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('bcrypt', () => ({ + default: bcryptMock, + ...bcryptMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +const makeRequest = (body: Record) => ({ + json: vi.fn().mockResolvedValue(body), +}); + +describe('Change password route', () => { + beforeEach(() => { + vi.clearAllMocks(); + requireAuthMock.mockImplementation((_req: any, handler: any) => + handler({ user: { id: 'user-1' } }) + ); + }); + + it('validates required fields', async () => { + const { POST } = await import('@/app/api/auth/change-password/route'); + + const response = await POST(makeRequest({ currentPassword: 'old' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/required/i); + }); + + it('rejects short passwords', async () => { + const { POST } = await import('@/app/api/auth/change-password/route'); + + const response = await POST( + makeRequest({ currentPassword: 'old', newPassword: 'short', confirmPassword: 'short' }) as any + ); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/at least 8 characters/i); + }); + + it('blocks non-local users', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + authProvider: 'plex', + authToken: 'enc-hash', + plexId: 'plex-1', + plexUsername: 'user', + }); + const { POST } = await import('@/app/api/auth/change-password/route'); + + const response = await POST( + makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any + ); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/local users/i); + }); + + it('rejects incorrect current password', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + authProvider: 'local', + authToken: 'enc-hash', + plexId: 'local-user', + plexUsername: 'user', + }); + encryptionMock.decrypt.mockReturnValue('hash'); + bcryptMock.compare.mockResolvedValue(false); + const { POST } = await import('@/app/api/auth/change-password/route'); + + const response = await POST( + makeRequest({ currentPassword: 'wrong', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any + ); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/incorrect/i); + }); + + it('returns error when decryption fails', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + authProvider: 'local', + authToken: 'enc-hash', + plexId: 'local-user', + plexUsername: 'user', + }); + encryptionMock.decrypt.mockImplementation(() => { + throw new Error('decrypt failed'); + }); + const { POST } = await import('@/app/api/auth/change-password/route'); + + const response = await POST( + makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any + ); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/verify current password/i); + }); + + it('updates password for local user', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + authProvider: 'local', + authToken: 'enc-hash', + plexId: 'local-user', + plexUsername: 'user', + }); + encryptionMock.decrypt.mockReturnValue('hash'); + bcryptMock.compare.mockResolvedValue(true); + bcryptMock.hash.mockResolvedValue('new-hash'); + encryptionMock.encrypt.mockReturnValue('enc-new-hash'); + prismaMock.user.update.mockResolvedValue({}); + const { POST } = await import('@/app/api/auth/change-password/route'); + + const response = await POST( + makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ authToken: 'enc-new-hash' }), + }) + ); + }); +}); diff --git a/tests/api/auth-is-local-admin.routes.test.ts b/tests/api/auth-is-local-admin.routes.test.ts new file mode 100644 index 0000000..f6a103a --- /dev/null +++ b/tests/api/auth-is-local-admin.routes.test.ts @@ -0,0 +1,47 @@ +/** + * Component: Is Local Admin API Route Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const requireAuthMock = vi.fn(); +const isLocalAdminMock = vi.fn(); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + isLocalAdmin: isLocalAdminMock, +})); + +describe('Is local admin route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns false when request has no user', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler({})); + const { GET } = await import('@/app/api/auth/is-local-admin/route'); + + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.isLocalAdmin).toBe(false); + expect(isLocalAdminMock).not.toHaveBeenCalled(); + }); + + it('returns local admin status for user', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => + handler({ user: { id: 'user-1' } }) + ); + isLocalAdminMock.mockResolvedValue(true); + const { GET } = await import('@/app/api/auth/is-local-admin/route'); + + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.isLocalAdmin).toBe(true); + expect(isLocalAdminMock).toHaveBeenCalledWith('user-1'); + }); +}); diff --git a/tests/api/auth-local.routes.test.ts b/tests/api/auth-local.routes.test.ts new file mode 100644 index 0000000..4d0b550 --- /dev/null +++ b/tests/api/auth-local.routes.test.ts @@ -0,0 +1,123 @@ +/** + * Component: Local Auth API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const localAuthProviderMock = { + handleCallback: vi.fn(), + register: vi.fn(), +}; + +vi.mock('@/lib/services/auth/LocalAuthProvider', () => ({ + LocalAuthProvider: class { + handleCallback = localAuthProviderMock.handleCallback; + register = localAuthProviderMock.register; + }, +})); + +const makeRequest = (body: any, headers?: Record) => ({ + json: vi.fn().mockResolvedValue(body), + headers: { + get: (key: string) => headers?.[key.toLowerCase()] || null, + }, +}); + +describe('Local auth routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DISABLE_LOCAL_LOGIN; + }); + + it('rejects login when local auth is disabled', async () => { + process.env.DISABLE_LOCAL_LOGIN = 'true'; + const { POST } = await import('@/app/api/auth/local/login/route'); + + const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Local login is disabled'); + }); + + it('rejects login when username or password missing', async () => { + const { POST } = await import('@/app/api/auth/local/login/route'); + + const response = await POST(makeRequest({ username: 'user' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('Username and password are required'); + }); + + it('logs in successfully with local credentials', async () => { + localAuthProviderMock.handleCallback.mockResolvedValue({ + success: true, + user: { id: 'u1' }, + tokens: { accessToken: 'access', refreshToken: 'refresh' }, + }); + const { POST } = await import('@/app/api/auth/local/login/route'); + + const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.accessToken).toBe('access'); + }); + + it('returns pending approval for local login', async () => { + localAuthProviderMock.handleCallback.mockResolvedValue({ + success: false, + requiresApproval: true, + }); + const { POST } = await import('@/app/api/auth/local/login/route'); + + const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.pendingApproval).toBe(true); + }); + + it('registers a local user and returns tokens', async () => { + localAuthProviderMock.register.mockResolvedValue({ + success: true, + user: { id: 'u2' }, + tokens: { accessToken: 'access', refreshToken: 'refresh' }, + }); + const { POST } = await import('@/app/api/auth/register/route'); + + const request = makeRequest({ username: 'user', password: 'pass' }, { 'x-forwarded-for': 'ip-1' }); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.refreshToken).toBe('refresh'); + }); + + it('rate limits repeated registration attempts by IP', async () => { + localAuthProviderMock.register.mockResolvedValue({ + success: true, + user: { id: 'u3' }, + tokens: { accessToken: 'access', refreshToken: 'refresh' }, + }); + const { POST } = await import('@/app/api/auth/register/route'); + + const request = makeRequest({ username: 'user', password: 'pass' }, { 'x-forwarded-for': 'ip-2' }); + for (let i = 0; i < 5; i += 1) { + const response = await POST(request as any); + expect(response.status).toBe(200); + } + + const blocked = await POST(request as any); + const payload = await blocked.json(); + + expect(blocked.status).toBe(429); + expect(payload.error).toMatch(/Too many registration attempts/); + }); +}); + + diff --git a/tests/api/auth-misc.routes.test.ts b/tests/api/auth-misc.routes.test.ts new file mode 100644 index 0000000..e7cbc8b --- /dev/null +++ b/tests/api/auth-misc.routes.test.ts @@ -0,0 +1,120 @@ +/** + * Component: Auth Misc API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const verifyRefreshTokenMock = vi.hoisted(() => vi.fn()); +const generateAccessTokenMock = vi.hoisted(() => vi.fn()); +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + verifyRefreshToken: verifyRefreshTokenMock, + generateAccessToken: generateAccessTokenMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + ConfigurationService: class { + get = configServiceMock.get; + }, +})); + +describe('Auth misc routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + delete process.env.DISABLE_LOCAL_LOGIN; + }); + + it('logs out successfully', async () => { + const { POST } = await import('@/app/api/auth/logout/route'); + + const response = await POST(); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + }); + + it('returns current user details with local admin flag', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + plexId: 'local-admin', + plexUsername: 'admin', + plexEmail: 'admin@example.com', + role: 'admin', + isSetupAdmin: true, + avatarUrl: null, + authProvider: 'local', + createdAt: new Date(), + lastLoginAt: new Date(), + }); + + const { GET } = await import('@/app/api/auth/me/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.user.isLocalAdmin).toBe(true); + expect(payload.user.username).toBe('admin'); + }); + + it('refreshes access token when refresh token is valid', async () => { + verifyRefreshTokenMock.mockReturnValue({ sub: 'user-1' }); + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + plexId: 'plex-1', + plexUsername: 'user', + role: 'user', + }); + generateAccessTokenMock.mockReturnValue('access-token'); + + const { POST } = await import('@/app/api/auth/refresh/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'refresh' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.accessToken).toBe('access-token'); + }); + + it('returns provider info for audiobookshelf mode', async () => { + configServiceMock.get + .mockResolvedValueOnce('audiobookshelf') + .mockResolvedValueOnce('prowlarr') + .mockResolvedValueOnce('http://prowlarr') + .mockResolvedValueOnce('true') + .mockResolvedValueOnce('true') + .mockResolvedValueOnce('MyOIDC'); + + prismaMock.user.count.mockResolvedValueOnce(1); + + const { GET } = await import('@/app/api/auth/providers/route'); + const response = await GET(); + const payload = await response.json(); + + expect(payload.backendMode).toBe('audiobookshelf'); + expect(payload.providers).toContain('oidc'); + expect(payload.registrationEnabled).toBe(true); + expect(payload.oidcProviderName).toBe('MyOIDC'); + }); +}); + + diff --git a/tests/api/auth-oidc.routes.test.ts b/tests/api/auth-oidc.routes.test.ts new file mode 100644 index 0000000..9609eac --- /dev/null +++ b/tests/api/auth-oidc.routes.test.ts @@ -0,0 +1,76 @@ +/** + * Component: OIDC Auth API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const authProviderMock = vi.hoisted(() => ({ + initiateLogin: vi.fn(), + handleCallback: vi.fn(), +})); +const getBaseUrlMock = vi.hoisted(() => vi.fn(() => 'http://app')); + +vi.mock('@/lib/services/auth', () => ({ + getAuthProvider: async () => authProviderMock, +})); + +vi.mock('@/lib/utils/url', () => ({ + getBaseUrl: getBaseUrlMock, +})); + +const makeRequest = (url: string) => ({ + nextUrl: new URL(url), +}); + +describe('OIDC auth routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('redirects to provider on login', async () => { + authProviderMock.initiateLogin.mockResolvedValue({ redirectUrl: 'http://oidc/login' }); + const { GET } = await import('@/app/api/auth/oidc/login/route'); + + const response = await GET(); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://oidc/login'); + }); + + it('redirects to login on missing code/state', async () => { + const { GET } = await import('@/app/api/auth/oidc/callback/route'); + + const response = await GET(makeRequest('http://app/api/auth/oidc/callback') as any); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toContain('/login?error='); + }); + + it('redirects with pending approval when required', async () => { + authProviderMock.handleCallback.mockResolvedValue({ success: false, requiresApproval: true }); + const { GET } = await import('@/app/api/auth/oidc/callback/route'); + + const response = await GET(makeRequest('http://app/api/auth/oidc/callback?code=abc&state=def') as any); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('http://app/login?pending=approval'); + }); + + it('returns HTML response for successful callback', async () => { + authProviderMock.handleCallback.mockResolvedValue({ + success: true, + tokens: { accessToken: 'access', refreshToken: 'refresh' }, + user: { id: 'u1', username: 'user', email: 'a@b.com', avatarUrl: null, isAdmin: false }, + isFirstLogin: false, + }); + + const { GET } = await import('@/app/api/auth/oidc/callback/route'); + const response = await GET(makeRequest('http://app/api/auth/oidc/callback?code=abc&state=def') as any); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/html'); + }); +}); + + diff --git a/tests/api/auth-plex.routes.test.ts b/tests/api/auth-plex.routes.test.ts new file mode 100644 index 0000000..54bb700 --- /dev/null +++ b/tests/api/auth-plex.routes.test.ts @@ -0,0 +1,309 @@ +/** + * Component: Plex Auth API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const plexServiceMock = vi.hoisted(() => ({ + requestPin: vi.fn(), + getOAuthUrl: vi.fn(), + checkPin: vi.fn(), + getUserInfo: vi.fn(), + verifyServerAccess: vi.fn(), + getHomeUsers: vi.fn(), + switchHomeUser: vi.fn(), +})); +const encryptionServiceMock = vi.hoisted(() => ({ + encrypt: vi.fn((value: string) => `enc-${value}`), +})); +const configServiceMock = vi.hoisted(() => ({ + getPlexConfig: vi.fn(), +})); +const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token')); +const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token')); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => plexServiceMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionServiceMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + generateAccessToken: generateAccessTokenMock, + generateRefreshToken: generateRefreshTokenMock, +})); + +const makeRequest = (url: string, headers?: Record) => ({ + nextUrl: new URL(url), + headers: { + get: (key: string) => headers?.[key.toLowerCase()] || null, + }, + json: vi.fn(), +}); + +describe('Plex auth routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('initiates Plex login and returns auth URL', async () => { + plexServiceMock.requestPin.mockResolvedValue({ id: 1, code: 'code-1' }); + plexServiceMock.getOAuthUrl.mockReturnValue('http://plex/auth'); + + const { POST } = await import('@/app/api/auth/plex/login/route'); + const response = await POST(makeRequest('http://localhost/api/auth/plex/login', { origin: 'http://app' }) as any); + const payload = await response.json(); + + expect(payload.authUrl).toBe('http://plex/auth'); + expect(plexServiceMock.getOAuthUrl).toHaveBeenCalledWith('code-1', 1, 'http://app/api/auth/plex/callback'); + }); + + it('returns 400 when pinId is missing', async () => { + const { GET } = await import('@/app/api/auth/plex/callback/route'); + + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback') as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 202 when waiting for authorization', async () => { + plexServiceMock.checkPin.mockResolvedValue(null); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=1') as any); + const payload = await response.json(); + + expect(response.status).toBe(202); + expect(payload.authorized).toBe(false); + }); + + it('denies access when Plex server is not configured', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' }); + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null }); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any); + const payload = await response.json(); + + expect(response.status).toBe(503); + expect(payload.error).toBe('ConfigurationError'); + }); + + it('denies access when machine identifier is missing', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' }); + configServiceMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + machineIdentifier: null, + }); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any); + const payload = await response.json(); + + expect(response.status).toBe(503); + expect(payload.error).toBe('ConfigurationError'); + }); + + it('rejects when user lacks server access', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' }); + configServiceMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(false); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('AccessDenied'); + }); + + it('returns errors when Plex user info is incomplete', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: '' }); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('OAuthError'); + expect(payload.details).toContain('Username is missing'); + }); + + it('returns profile selection info when multiple home users exist', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' }); + configServiceMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(true); + plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=3', { accept: 'application/json' }) as any); + const payload = await response.json(); + + expect(payload.requiresProfileSelection).toBe(true); + expect(payload.homeUsers).toBe(2); + }); + + it('returns HTML redirect for browser profile selection', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' }); + configServiceMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(true); + plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET( + makeRequest('http://localhost/api/auth/plex/callback?pinId=3', { + accept: 'text/html', + host: 'example.com', + 'x-forwarded-proto': 'https', + }) as any + ); + const html = await response.text(); + + expect(response.headers.get('content-type')).toContain('text/html'); + expect(html).toContain('sessionStorage.setItem'); + expect(html).toContain('https://example.com/auth/select-profile?pinId=3'); + }); + + it('returns tokens for successful Plex auth', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' }); + configServiceMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(true); + plexServiceMock.getHomeUsers.mockResolvedValue([]); + prismaMock.user.count.mockResolvedValue(0); + prismaMock.user.upsert.mockResolvedValue({ + id: 'user-1', + plexId: 'plex-1', + plexUsername: 'user', + plexEmail: null, + role: 'admin', + avatarUrl: null, + }); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=4', { accept: 'application/json' }) as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.accessToken).toBe('access-token'); + }); + + it('returns HTML redirect with cookies for browser auth', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ + id: 'plex-1', + username: 'user', + email: 'user@example.com', + thumb: '/t', + }); + configServiceMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(true); + plexServiceMock.getHomeUsers.mockResolvedValue([]); + prismaMock.user.count.mockResolvedValue(1); + prismaMock.user.upsert.mockResolvedValue({ + id: 'user-1', + plexId: 'plex-1', + plexUsername: 'user', + plexEmail: 'user@example.com', + role: 'user', + avatarUrl: '/t', + }); + + const { GET } = await import('@/app/api/auth/plex/callback/route'); + const response = await GET( + makeRequest('http://localhost/api/auth/plex/callback?pinId=4', { + accept: 'text/html', + host: 'example.com', + 'x-forwarded-proto': 'https', + }) as any + ); + const html = await response.text(); + + expect(response.headers.get('content-type')).toContain('text/html'); + expect(response.cookies.get('accessToken')?.value).toBe('access-token'); + expect(response.cookies.get('refreshToken')?.value).toBe('refresh-token'); + expect(html).toContain('#authData='); + }); + + it('returns Plex home users when token is provided', async () => { + plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }]); + + const { GET } = await import('@/app/api/auth/plex/home-users/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.users).toHaveLength(1); + }); + + it('switches Plex profile using provided profile info', async () => { + plexServiceMock.switchHomeUser.mockResolvedValue('profile-token'); + prismaMock.user.count.mockResolvedValue(1); + prismaMock.user.upsert.mockResolvedValue({ + id: 'user-2', + plexId: 'uuid-1', + plexUsername: 'Profile', + plexEmail: null, + role: 'user', + avatarUrl: null, + }); + + const { POST } = await import('@/app/api/auth/plex/switch-profile/route'); + const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' }); + request.json.mockResolvedValue({ + userId: 'home-1', + pin: '1234', + profileInfo: { uuid: 'uuid-1', friendlyName: 'Profile' }, + }); + + const response = await POST(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.accessToken).toBe('access-token'); + }); +}); + + diff --git a/tests/api/bookdate-test-connection.routes.test.ts b/tests/api/bookdate-test-connection.routes.test.ts new file mode 100644 index 0000000..a3dc202 --- /dev/null +++ b/tests/api/bookdate-test-connection.routes.test.ts @@ -0,0 +1,446 @@ +/** + * Component: BookDate Test Connection Route Tests + * Documentation: documentation/features/bookdate-prd.md + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const encryptionMock = vi.hoisted(() => ({ + decrypt: vi.fn(), +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +describe('BookDate test connection route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('rejects unauthenticated use of saved keys', async () => { + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(401); + expect(payload.error).toMatch(/Authentication required/i); + }); + + it('requires API key for OpenAI unauthenticated requests', async () => { + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'openai' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/API key is required/); + }); + + it('requires baseUrl for custom unauthenticated requests', async () => { + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'custom' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Base URL is required/); + }); + + it('requires provider for unauthenticated requests', async () => { + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({}), + } as any); + + expect(response.status).toBe(400); + }); + + it('validates provider for unauthenticated requests', async () => { + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'invalid', apiKey: 'x' }), + } as any); + + expect(response.status).toBe(400); + }); + + it('returns filtered OpenAI models for unauthenticated requests', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: 'gpt-4-1' }, + { id: 'gpt-3.5-turbo' }, + { id: 'gpt-4-0' }, + ], + }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models.map((m: any) => m.id)).toEqual(['gpt-4-0', 'gpt-4-1']); + }); + + it('returns filtered OpenAI models for authenticated requests', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: 'gpt-4-2' }, + { id: 'gpt-3.5-turbo' }, + { id: 'gpt-4-1' }, + ], + }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models.map((m: any) => m.id)).toEqual(['gpt-4-1', 'gpt-4-2']); + }); + + it('returns Claude models for unauthenticated requests', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models.length).toBe(4); + }); + + it('returns OpenAI error for unauthenticated requests with invalid key', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + text: vi.fn().mockResolvedValue('bad key'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'bad' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid OpenAI API key/i); + }); + + it('returns error when saved config is missing', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + prismaMock.bookDateConfig.findFirst.mockResolvedValue(null); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/No saved configuration/i); + }); + + it('returns error when saved key decryption fails', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + prismaMock.bookDateConfig.findFirst.mockResolvedValue({ apiKey: 'enc-key', baseUrl: null }); + encryptionMock.decrypt.mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Failed to decrypt/i); + }); + + it('requires API key for authenticated OpenAI requests', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: '' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/API key is required/); + }); + + it('requires baseUrl when using saved custom config without baseUrl', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + prismaMock.bookDateConfig.findFirst.mockResolvedValue({ + apiKey: 'enc-key', + baseUrl: null, + }); + encryptionMock.decrypt.mockReturnValue('decrypted'); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/No saved base URL/i); + }); + + it('uses saved key for custom provider and parses OpenAI format', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + prismaMock.bookDateConfig.findFirst.mockResolvedValue({ + apiKey: 'enc-key', + baseUrl: 'http://custom', + }); + encryptionMock.decrypt.mockReturnValue('decrypted'); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-a', name: 'Model A' }], + }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models).toEqual([{ id: 'model-a', name: 'Model A' }]); + }); + + it('validates custom base URLs for authenticated requests', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'ftp://bad' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid base URL/i); + }); + + it('validates custom base URLs for unauthenticated requests', async () => { + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'ftp://bad' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid base URL/i); + }); + + it('returns custom provider models for authenticated requests', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([ + { id: 'model-a' }, + { id: 'model-b', name: 'Model B' }, + ]), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: '' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models).toEqual([ + { id: 'model-a', name: 'model-a' }, + { id: 'model-b', name: 'Model B' }, + ]); + }); + + it('returns helpful message when custom models list cannot be parsed', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ unexpected: true }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.message).toMatch(/could not parse models list/i); + }); + + it('returns network error when custom provider fetch fails', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const fetchMock = vi.fn().mockRejectedValue(new Error('network down')); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Network error/i); + }); + + it('returns 400 (not 401) when custom provider returns 401 to prevent logout', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Unauthorized'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'bad-key' }), + } as any); + + const payload = await response.json(); + // Should return 400, not 401, to prevent fetchWithAuth from logging user out + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Failed to connect to custom provider/i); + }); + + it('returns 400 (not 401) when custom provider returns 401 during unauthenticated request', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Unauthorized'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'bad-key' }), + } as any); + + const payload = await response.json(); + // Should return 400, not 401, to prevent fetchWithAuth from logging user out + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Failed to connect to custom provider/i); + }); + + it('allows custom provider when saved key decryption fails', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + prismaMock.bookDateConfig.findFirst.mockResolvedValue({ + apiKey: 'enc-key', + baseUrl: 'http://custom', + }); + encryptionMock.decrypt.mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([{ id: 'model-a' }]), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + 'http://custom/models', + expect.objectContaining({ + method: 'GET', + headers: {}, + }) + ); + }); +}); diff --git a/tests/api/bookdate.routes.test.ts b/tests/api/bookdate.routes.test.ts new file mode 100644 index 0000000..e845d74 --- /dev/null +++ b/tests/api/bookdate.routes.test.ts @@ -0,0 +1,501 @@ +/** + * Component: BookDate API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const requireAdminMock = vi.hoisted(() => vi.fn()); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((value: string) => `enc-${value}`), + decrypt: vi.fn((value: string) => value.replace('enc-', '')), +})); +const configServiceMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), +})); +const jobQueueMock = vi.hoisted(() => ({ + addSearchJob: vi.fn(), +})); +const bookdateHelpersMock = vi.hoisted(() => ({ + buildAIPrompt: vi.fn(), + callAI: vi.fn(), + matchToAudnexus: vi.fn(), + isInLibrary: vi.fn(), + isAlreadyRequested: vi.fn(), + isAlreadySwiped: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: requireAdminMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/bookdate/helpers', () => bookdateHelpersMock); + +describe('BookDate routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1', role: 'admin' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + requireAdminMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns BookDate config without API key', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + id: 'cfg-1', + apiKey: 'secret', + provider: 'openai', + model: 'gpt', + }); + + const { GET } = await import('@/app/api/bookdate/config/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.config.apiKey).toBeUndefined(); + expect(payload.config.provider).toBe('openai'); + }); + + it('returns null config when not configured', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + + const { GET } = await import('@/app/api/bookdate/config/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.config).toBeNull(); + }); + + it('saves BookDate config and clears recommendations', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + prismaMock.bookDateConfig.create.mockResolvedValueOnce({ + id: 'cfg-2', + provider: 'openai', + model: 'gpt', + apiKey: 'enc-secret', + }); + prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); + authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'secret', model: 'gpt' }); + + const { POST } = await import('@/app/api/bookdate/config/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(prismaMock.bookDateRecommendation.deleteMany).toHaveBeenCalled(); + }); + + it('rejects missing required fields when saving config', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'key' }); + + const { POST } = await import('@/app/api/bookdate/config/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Missing required fields/); + }); + + it('rejects invalid provider when saving config', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + authRequest.json.mockResolvedValue({ provider: 'invalid', apiKey: 'key', model: 'gpt' }); + + const { POST } = await import('@/app/api/bookdate/config/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid provider/); + }); + + it('rejects custom provider without baseUrl', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + authRequest.json.mockResolvedValue({ provider: 'custom', apiKey: '', model: 'model-x' }); + + const { POST } = await import('@/app/api/bookdate/config/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Base URL is required/); + }); + + it('rejects custom provider with invalid baseUrl', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + authRequest.json.mockResolvedValue({ + provider: 'custom', + apiKey: '', + model: 'model-x', + baseUrl: 'ftp://bad', + }); + + const { POST } = await import('@/app/api/bookdate/config/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid base URL/); + }); + + it('updates existing config without a new API key', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + id: 'cfg-9', + apiKey: 'enc-existing', + }); + prismaMock.bookDateConfig.update.mockResolvedValueOnce({ + id: 'cfg-9', + provider: 'openai', + model: 'gpt-4', + apiKey: 'enc-existing', + }); + authRequest.json.mockResolvedValue({ provider: 'openai', model: 'gpt-4' }); + + const { POST } = await import('@/app/api/bookdate/config/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(prismaMock.bookDateConfig.update).toHaveBeenCalled(); + }); + + it('creates custom config with empty API key', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + prismaMock.bookDateConfig.create.mockResolvedValueOnce({ + id: 'cfg-10', + provider: 'custom', + model: 'model-x', + apiKey: 'enc-', + }); + prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); + authRequest.json.mockResolvedValue({ + provider: 'custom', + model: 'model-x', + baseUrl: 'http://custom', + }); + + const { POST } = await import('@/app/api/bookdate/config/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(encryptionMock.encrypt).toHaveBeenCalledWith(''); + }); + + it('deletes BookDate config', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ id: 'cfg-3' }); + prismaMock.bookDateConfig.delete.mockResolvedValueOnce({}); + prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); + prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 }); + + const { DELETE } = await import('@/app/api/bookdate/config/route'); + const response = await DELETE({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('returns 404 when deleting missing BookDate config', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/bookdate/config/route'); + const response = await DELETE({} as any); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/Configuration not found/); + }); + + it('returns BookDate preferences', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + bookDateOnboardingComplete: true, + }); + configServiceMock.getBackendMode.mockResolvedValueOnce('plex'); + + const { GET } = await import('@/app/api/bookdate/preferences/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.libraryScope).toBe('full'); + expect(payload.onboardingComplete).toBe(true); + }); + + it('updates BookDate preferences', async () => { + configServiceMock.getBackendMode.mockResolvedValueOnce('plex'); + prismaMock.user.update.mockResolvedValueOnce({ + bookDateLibraryScope: 'rated', + bookDateCustomPrompt: 'Prompt', + bookDateOnboardingComplete: true, + }); + authRequest.json.mockResolvedValue({ libraryScope: 'rated', customPrompt: 'Prompt', onboardingComplete: true }); + + const { PUT } = await import('@/app/api/bookdate/preferences/route'); + const response = await PUT({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.libraryScope).toBe('rated'); + }); + + it('returns cached recommendations without calling AI', async () => { + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]); + + const { GET } = await import('@/app/api/bookdate/recommendations/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.source).toBe('cache'); + expect(payload.recommendations).toHaveLength(1); + }); + + it('returns error when recommendations are disabled', async () => { + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: false, + }); + + const { GET } = await import('@/app/api/bookdate/recommendations/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/not configured/i); + }); + + it('returns 404 when recommendation user is missing', async () => { + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + }); + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { GET } = await import('@/app/api/bookdate/recommendations/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/i); + }); + + it('generates and stores recommendations when AI returns matches', async () => { + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + baseUrl: null, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + }); + bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); + bookdateHelpersMock.callAI.mockResolvedValueOnce({ + recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }], + }); + bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false); + bookdateHelpersMock.isInLibrary.mockResolvedValue(false); + bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({ + asin: 'ASIN1', + title: 'Title', + author: 'Author', + narrator: null, + rating: null, + description: null, + coverUrl: null, + }); + bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false); + (prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 }); + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]); + + const { GET } = await import('@/app/api/bookdate/recommendations/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.source).toBe('generated'); + expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled(); + expect(payload.recommendations).toHaveLength(1); + }); + + it('returns error when generating recommendations without config', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/bookdate/generate/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/not configured/); + }); + + it('returns 404 when no new recommendations can be matched', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + baseUrl: null, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + }); + bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); + bookdateHelpersMock.callAI.mockResolvedValueOnce({ + recommendations: [{ title: 'Title', author: 'Author' }], + }); + bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false); + bookdateHelpersMock.isInLibrary.mockResolvedValue(false); + bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/bookdate/generate/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/Could not find any new recommendations/i); + }); + + it('stores generated recommendations from the AI', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + baseUrl: null, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + }); + bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); + bookdateHelpersMock.callAI.mockResolvedValueOnce({ + recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }], + }); + bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false); + bookdateHelpersMock.isInLibrary.mockResolvedValue(false); + bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({ + asin: 'ASIN1', + title: 'Title', + author: 'Author', + narrator: null, + rating: null, + description: null, + coverUrl: null, + }); + bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false); + (prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 }); + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-2' }]); + + const { POST } = await import('@/app/api/bookdate/generate/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.source).toBe('generated'); + expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled(); + expect(payload.recommendations).toHaveLength(1); + }); + + it('records swipe and creates request on right swipe', async () => { + authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false }); + prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ + id: 'rec-1', + userId: 'user-1', + title: 'Title', + author: 'Author', + audnexusAsin: 'ASIN', + }); + prismaMock.bookDateSwipe.create.mockResolvedValueOnce({}); + prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); + prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' }); + + const { POST } = await import('@/app/api/bookdate/swipe/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); + }); + + it('undoes last swipe', async () => { + prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({ + id: 'swipe-1', + recommendation: { id: 'rec-1', createdAt: new Date() }, + }); + prismaMock.bookDateRecommendation.findFirst.mockResolvedValueOnce(null); + prismaMock.bookDateSwipe.delete.mockResolvedValueOnce({}); + prismaMock.bookDateRecommendation.update.mockResolvedValueOnce({ id: 'rec-1' }); + + const { POST } = await import('@/app/api/bookdate/undo/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('clears all swipes as admin', async () => { + prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 }); + prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 }); + + const { DELETE } = await import('@/app/api/bookdate/swipes/route'); + const response = await DELETE({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + }); + + it('tests BookDate connection without auth', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [{ id: 'model-1' }] }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: '' }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.models[0].id).toBe('model-1'); + }); +}); + + diff --git a/tests/api/cache.routes.test.ts b/tests/api/cache.routes.test.ts new file mode 100644 index 0000000..3aedc80 --- /dev/null +++ b/tests/api/cache.routes.test.ts @@ -0,0 +1,53 @@ +/** + * Component: Cache API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + readFile: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } })); + +describe('Thumbnail cache route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects invalid filenames', async () => { + const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ filename: '../bad' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('Invalid filename'); + }); + + it('returns 404 when file is missing', async () => { + fsMock.access.mockRejectedValueOnce(new Error('missing')); + const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ filename: 'file.jpg' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('File not found'); + }); + + it('serves cached image with content type', async () => { + fsMock.access.mockResolvedValueOnce(undefined); + fsMock.readFile.mockResolvedValueOnce(Buffer.from('data')); + const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ filename: 'file.png' }) }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('image/png'); + }); +}); + + diff --git a/tests/api/config.routes.test.ts b/tests/api/config.routes.test.ts new file mode 100644 index 0000000..6c290a1 --- /dev/null +++ b/tests/api/config.routes.test.ts @@ -0,0 +1,59 @@ +/** + * Component: Config API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const configServiceMock = vi.hoisted(() => ({ + setMany: vi.fn(), + getAll: vi.fn(), + getCategory: vi.fn(), +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('Config API routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns full configuration', async () => { + configServiceMock.getAll.mockResolvedValue({ plex_url: 'http://plex' }); + const { GET } = await import('@/app/api/config/route'); + + const response = await GET(); + const payload = await response.json(); + + expect(payload.config.plex_url).toBe('http://plex'); + }); + + it('updates configuration values', async () => { + const { PUT } = await import('@/app/api/config/route'); + const response = await PUT({ + json: vi.fn().mockResolvedValue({ + updates: [{ key: 'plex_url', value: 'http://plex' }], + }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.updated).toBe(1); + expect(configServiceMock.setMany).toHaveBeenCalled(); + }); + + it('returns category configuration', async () => { + configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' }); + const { GET } = await import('@/app/api/config/[category]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) }); + const payload = await response.json(); + + expect(payload.category).toBe('plex'); + expect(payload.config.plex_url).toBe('http://plex'); + }); +}); + + diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts new file mode 100644 index 0000000..73733cc --- /dev/null +++ b/tests/api/requests-actions.routes.test.ts @@ -0,0 +1,244 @@ +/** + * Component: Request Action API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() })); +const rankTorrentsMock = vi.hoisted(() => vi.fn()); +const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); +const configState = vi.hoisted(() => ({ + values: new Map(), +})); +const jobQueueMock = vi.hoisted(() => ({ + addSearchJob: vi.fn(), + addDownloadJob: vi.fn(), +})); +const downloadEbookMock = vi.hoisted(() => vi.fn()); +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, + requireAdmin: vi.fn((req: any, handler: any) => handler()), +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + getProwlarrService: async () => prowlarrMock, +})); + +vi.mock('@/lib/utils/ranking-algorithm', () => ({ + rankTorrents: rankTorrentsMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/ebook-scraper', () => ({ + downloadEbook: downloadEbookMock, +})); + +vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } })); + +describe('Request action routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + configState.values.clear(); + authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + prismaMock.configuration.findUnique.mockImplementation( + async ({ where: { key } }: { where: { key: string } }) => { + const value = configState.values.get(key); + return value !== undefined ? { value } : null; + } + ); + }); + + it('performs interactive search and ranks results', async () => { + authRequest.json.mockResolvedValue({}); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-1', + userId: 'user-1', + audiobook: { title: 'Title', author: 'Author' }, + }); + configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }])); + configServiceMock.get.mockResolvedValueOnce(null); + prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]); + rankTorrentsMock.mockReturnValueOnce([ + { title: 'Result', score: 50, breakdown: { matchScore: 50, formatScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 }, + ]); + + const { POST } = await import('@/app/api/requests/[id]/interactive-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.results[0].rank).toBe(1); + }); + + it('triggers manual search job', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-2', + userId: 'user-1', + status: 'failed', + audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' }, + }); + prismaMock.request.update.mockResolvedValueOnce({ id: 'req-2', status: 'pending' }); + + const { POST } = await import('@/app/api/requests/[id]/manual-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-2' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); + }); + + it('selects a torrent and queues download', async () => { + authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } }); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-3', + userId: 'user-1', + audiobook: { id: 'ab-2', title: 'Title', author: 'Author' }, + }); + prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading' }); + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-3' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addDownloadJob).toHaveBeenCalled(); + }); + + it('returns error when ebook sidecar is disabled', async () => { + configState.values.set('ebook_sidecar_enabled', 'false'); + + const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/not enabled/); + }); + + it('returns 404 when request is not found', async () => { + configState.values.set('ebook_sidecar_enabled', 'true'); + prismaMock.request.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/not found/); + }); + + it('returns 400 when request status is not eligible for ebook fetch', async () => { + configState.values.set('ebook_sidecar_enabled', 'true'); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-5', + status: 'pending', + audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' }, + }); + + const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-5' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Cannot fetch e-book/); + }); + + it('returns 400 when audiobook directory is missing', async () => { + configState.values.set('ebook_sidecar_enabled', 'true'); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-6', + status: 'downloaded', + audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' }, + }); + fsMock.access.mockRejectedValueOnce(new Error('missing')); + + const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/directory not found/); + }); + + it('downloads ebook and returns success', async () => { + configState.values.set('ebook_sidecar_enabled', 'true'); + configState.values.set('media_dir', '/media/audiobooks'); + configState.values.set('ebook_sidecar_preferred_format', 'epub'); + configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example'); + configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr'); + + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-7', + status: 'available', + audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' }, + }); + prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2022-05-01' }); + fsMock.access.mockResolvedValueOnce(undefined); + downloadEbookMock.mockResolvedValueOnce({ + success: true, + format: 'epub', + filePath: '/media/audiobooks/Author/Title (2022) ASIN123/Title.epub', + }); + + const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-7' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(downloadEbookMock).toHaveBeenCalledWith( + 'ASIN123', + 'Title', + 'Author', + expect.stringContaining('Title (2022) ASIN123'), + 'epub', + 'https://ebooks.example', + undefined, + 'http://flaresolverr' + ); + }); + + it('returns failure payload when ebook download fails', async () => { + configState.values.set('ebook_sidecar_enabled', 'true'); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-8', + status: 'downloaded', + audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' }, + }); + fsMock.access.mockResolvedValueOnce(undefined); + downloadEbookMock.mockResolvedValueOnce({ + success: false, + error: 'Download failed', + }); + + const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-8' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(false); + expect(payload.message).toMatch(/Download failed/); + }); +}); + + diff --git a/tests/api/requests-id.route.test.ts b/tests/api/requests-id.route.test.ts new file mode 100644 index 0000000..dc181a3 --- /dev/null +++ b/tests/api/requests-id.route.test.ts @@ -0,0 +1,322 @@ +/** + * Component: Request By ID API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() })); +const requireAuthMock = vi.hoisted(() => vi.fn()); +const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); +const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + getQBittorrentService: async () => qbtMock, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + getSABnzbdService: async () => sabnzbdMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +describe('Request by ID API routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + nextUrl: new URL('http://localhost/api/requests/req-1'), + json: vi.fn(), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 403 when user is not authorized to view the request', async () => { + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-1', + userId: 'user-2', + }); + + const { GET } = await import('@/app/api/requests/[id]/route'); + const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('returns request details for the owner', async () => { + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-1', + userId: 'user-1', + audiobook: { id: 'ab-1' }, + }); + + const { GET } = await import('@/app/api/requests/[id]/route'); + const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(payload.request.id).toBe('req-1'); + }); + + it('returns 404 when request does not exist', async () => { + prismaMock.request.findFirst.mockResolvedValueOnce(null); + + const { GET } = await import('@/app/api/requests/[id]/route'); + const response = await GET({} as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('NotFound'); + }); + + it('returns 401 when user is missing', async () => { + authRequest.user = null; + + const { GET } = await import('@/app/api/requests/[id]/route'); + const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('cancels a request', async () => { + authRequest.json.mockResolvedValue({ action: 'cancel' }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-2', + userId: 'user-1', + status: 'pending', + }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-2', + status: 'cancelled', + audiobook: { id: 'ab-1' }, + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.request.status).toBe('cancelled'); + }); + + it('returns 400 for invalid actions', async () => { + authRequest.json.mockResolvedValue({ action: 'unknown' }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-2', + userId: 'user-1', + status: 'pending', + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('rejects retry when status is not retryable', async () => { + authRequest.json.mockResolvedValue({ action: 'retry' }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-4', + userId: 'user-1', + status: 'available', + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('retries a failed request by enqueuing a search job', async () => { + authRequest.json.mockResolvedValue({ action: 'retry' }); + prismaMock.request.findFirst + .mockResolvedValueOnce({ + id: 'req-3', + userId: 'user-1', + status: 'failed', + }) + .mockResolvedValueOnce({ + id: 'req-3', + userId: 'user-1', + audiobook: { + id: 'ab-2', + title: 'Title', + author: 'Author', + audibleAsin: 'ASIN-2', + }, + }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-3', + status: 'pending', + audiobook: { id: 'ab-2' }, + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-3', { + id: 'ab-2', + title: 'Title', + author: 'Author', + asin: 'ASIN-2', + }); + }); + + it('retries an import via qBittorrent download history', async () => { + authRequest.json.mockResolvedValue({ action: 'retry' }); + prismaMock.request.findFirst + .mockResolvedValueOnce({ + id: 'req-5', + userId: 'user-1', + status: 'warn', + }) + .mockResolvedValueOnce({ + id: 'req-5', + userId: 'user-1', + audiobook: { id: 'ab-5' }, + downloadHistory: [{ torrentHash: 'hash-1', selected: true }], + }); + qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-5', + status: 'processing', + audiobook: { id: 'ab-5' }, + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-5' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-5', 'ab-5', '/downloads/Book'); + }); + + it('retries an import via SABnzbd download history', async () => { + authRequest.json.mockResolvedValue({ action: 'retry' }); + prismaMock.request.findFirst + .mockResolvedValueOnce({ + id: 'req-6', + userId: 'user-1', + status: 'awaiting_import', + }) + .mockResolvedValueOnce({ + id: 'req-6', + userId: 'user-1', + audiobook: { id: 'ab-6' }, + downloadHistory: [{ nzbId: 'nzb-1', selected: true }], + }); + sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/usenet/book' }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-6', + status: 'processing', + audiobook: { id: 'ab-6' }, + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-6' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-6', 'ab-6', '/usenet/book'); + }); + + it('returns 400 when download history is missing for import retry', async () => { + authRequest.json.mockResolvedValue({ action: 'retry' }); + prismaMock.request.findFirst + .mockResolvedValueOnce({ + id: 'req-7', + userId: 'user-1', + status: 'warn', + }) + .mockResolvedValueOnce({ + id: 'req-7', + userId: 'user-1', + audiobook: { id: 'ab-7' }, + downloadHistory: [], + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-7' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 400 when download client info is missing for import retry', async () => { + authRequest.json.mockResolvedValue({ action: 'retry' }); + prismaMock.request.findFirst + .mockResolvedValueOnce({ + id: 'req-8', + userId: 'user-1', + status: 'warn', + }) + .mockResolvedValueOnce({ + id: 'req-8', + userId: 'user-1', + audiobook: { id: 'ab-8' }, + downloadHistory: [{}], + }); + + const { PATCH } = await import('@/app/api/requests/[id]/route'); + const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-8' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('allows admins to delete requests', async () => { + authRequest.user = { id: 'admin-1', role: 'admin' }; + prismaMock.request.delete.mockResolvedValueOnce({}); + + const { DELETE } = await import('@/app/api/requests/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-4' } }); + }); + + it('blocks delete for non-admin users', async () => { + authRequest.user = { id: 'user-2', role: 'user' }; + + const { DELETE } = await import('@/app/api/requests/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-9' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); +}); + + diff --git a/tests/api/requests.route.test.ts b/tests/api/requests.route.test.ts new file mode 100644 index 0000000..159e02b --- /dev/null +++ b/tests/api/requests.route.test.ts @@ -0,0 +1,163 @@ +/** + * Component: Requests API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +let authRequest: any; + +const prismaMock = createPrismaMock(); +const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn() })); +const findPlexMatchMock = vi.hoisted(() => vi.fn()); +const requireAuthMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: findPlexMatchMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +describe('Requests API routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { + user: { id: 'user-1', role: 'user' }, + nextUrl: new URL('http://localhost/api/requests'), + json: vi.fn(), + }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 409 when an active request already exists', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-1', title: 'Title', author: 'Author' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-1', + status: 'downloaded', + userId: 'user-2', + user: { plexUsername: 'someone' }, + }); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('BeingProcessed'); + expect(findPlexMatchMock).not.toHaveBeenCalled(); + }); + + it('returns 409 when a Plex match already exists', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-2', title: 'Title', author: 'Author' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce({ plexGuid: 'plex-1' }); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('AlreadyAvailable'); + }); + + it('creates a new request and enqueues a search job', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-3', title: 'Title', author: 'Author' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); + prismaMock.audiobook.create.mockResolvedValueOnce({ + id: 'ab-1', + title: 'Title', + author: 'Author', + audibleAsin: 'ASIN-3', + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.request.create.mockResolvedValueOnce({ + id: 'req-2', + audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN-3' }, + user: { id: 'user-1', plexUsername: 'user' }, + }); + + const { POST } = await import('@/app/api/requests/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-2', { + id: 'ab-1', + title: 'Title', + author: 'Author', + asin: 'ASIN-3', + }); + }); + + it('skips auto-search when skipAutoSearch=true', async () => { + authRequest.nextUrl = new URL('http://localhost/api/requests?skipAutoSearch=true'); + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN-4', title: 'Title', author: 'Author' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + prismaMock.audiobook.findFirst.mockResolvedValueOnce(null); + prismaMock.audiobook.create.mockResolvedValueOnce({ + id: 'ab-2', + title: 'Title', + author: 'Author', + audibleAsin: 'ASIN-4', + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.request.create.mockResolvedValueOnce({ + id: 'req-3', + audiobook: { id: 'ab-2', title: 'Title', author: 'Author', audibleAsin: 'ASIN-4' }, + user: { id: 'user-1', plexUsername: 'user' }, + }); + + const { POST } = await import('@/app/api/requests/route'); + await POST({} as any); + + expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'awaiting_search' }), + }) + ); + }); + + it('filters requests for current user when not admin', async () => { + authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5'); + prismaMock.request.findMany.mockResolvedValueOnce([]); + + const { GET } = await import('@/app/api/requests/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + expect(prismaMock.request.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: 'user-1', status: 'pending' }), + take: 5, + }) + ); + }); +}); + + diff --git a/tests/api/setup-tests.routes.test.ts b/tests/api/setup-tests.routes.test.ts new file mode 100644 index 0000000..0fdaef6 --- /dev/null +++ b/tests/api/setup-tests.routes.test.ts @@ -0,0 +1,203 @@ +/** + * Component: Setup Validation API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const plexServiceMock = vi.hoisted(() => ({ + testConnection: vi.fn(), + getLibraries: vi.fn(), +})); +const qbtMock = vi.hoisted(() => ({ + testConnectionWithCredentials: vi.fn(), +})); +const sabnzbdMock = vi.hoisted(() => ({ + testConnection: vi.fn(), +})); +const prowlarrMock = vi.hoisted(() => ({ + getIndexers: vi.fn(), +})); +const issuerMock = vi.hoisted(() => ({ + discover: vi.fn(), +})); +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), +})); +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => plexServiceMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + QBittorrentService: { + testConnectionWithCredentials: qbtMock.testConnectionWithCredentials, + }, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + SABnzbdService: class { + constructor() {} + testConnection = sabnzbdMock.testConnection; + }, +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + ProwlarrService: class { + constructor() {} + getIndexers = prowlarrMock.getIndexers; + }, +})); + +vi.mock('openid-client', () => ({ + Issuer: issuerMock, +})); + +vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } })); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('Setup test routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('validates Plex connection and returns libraries', async () => { + plexServiceMock.testConnection.mockResolvedValue({ + success: true, + info: { platform: 'Plex', version: '1.0', machineIdentifier: 'machine' }, + }); + plexServiceMock.getLibraries.mockResolvedValue([{ id: '1', title: 'Books', type: 'book' }]); + + const { POST } = await import('@/app/api/setup/test-plex/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.libraries[0].id).toBe('1'); + }); + + it('tests qBittorrent credentials', async () => { + qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0'); + + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: 'pass', + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.version).toBe('4.0.0'); + }); + + it('tests SABnzbd connection', async () => { + sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' }); + + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + type: 'sabnzbd', + url: 'http://sab', + password: 'api-key', + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.version).toBe('3.0'); + }); + + it('tests Prowlarr indexers', async () => { + prowlarrMock.getIndexers.mockResolvedValue([ + { id: 1, name: 'Indexer', protocol: 'torrent', enable: true, capabilities: {} }, + { id: 2, name: 'Disabled', protocol: 'torrent', enable: false, capabilities: {} }, + ]); + + const { POST } = await import('@/app/api/setup/test-prowlarr/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.indexerCount).toBe(1); + }); + + it('validates OIDC issuer discovery', async () => { + issuerMock.discover.mockResolvedValue({ + issuer: 'http://issuer', + metadata: { + authorization_endpoint: 'http://issuer/auth', + token_endpoint: 'http://issuer/token', + userinfo_endpoint: 'http://issuer/user', + jwks_uri: 'http://issuer/jwks', + scopes_supported: ['openid'], + response_types_supported: ['code'], + }, + }); + + const { POST } = await import('@/app/api/setup/test-oidc/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + issuerUrl: 'http://issuer', + clientId: 'client', + clientSecret: 'secret', + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.issuer.authorizationEndpoint).toBe('http://issuer/auth'); + }); + + it('validates paths are writable', async () => { + fsMock.access.mockRejectedValueOnce(new Error('missing')); + fsMock.mkdir.mockResolvedValueOnce(undefined); + fsMock.writeFile.mockResolvedValueOnce(undefined); + fsMock.unlink.mockResolvedValueOnce(undefined); + + fsMock.access.mockResolvedValueOnce(undefined); + fsMock.writeFile.mockResolvedValueOnce(undefined); + fsMock.unlink.mockResolvedValueOnce(undefined); + + const { POST } = await import('@/app/api/setup/test-paths/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ downloadDir: '/downloads', mediaDir: '/media' }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.downloadDirValid).toBe(true); + }); + + it('tests Audiobookshelf connection with saved token', async () => { + configServiceMock.get.mockResolvedValueOnce('token'); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ libraries: [{ id: '1', name: 'Lib', mediaType: 'book', stats: { totalItems: 10 } }] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/setup/test-abs/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ serverUrl: 'http://abs', apiToken: '********' }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.libraries[0].id).toBe('1'); + }); +}); + + diff --git a/tests/api/setup.routes.test.ts b/tests/api/setup.routes.test.ts new file mode 100644 index 0000000..ccf17ef --- /dev/null +++ b/tests/api/setup.routes.test.ts @@ -0,0 +1,196 @@ +/** + * Component: Setup API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const bcryptMock = vi.hoisted(() => ({ + hash: vi.fn(), +})); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((value: string) => `enc-${value}`), +})); +const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token')); +const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token')); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('bcrypt', () => ({ + default: bcryptMock, + ...bcryptMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + generateAccessToken: generateAccessTokenMock, + generateRefreshToken: generateRefreshTokenMock, +})); + +describe('Setup routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns setup status from configuration', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'true' }); + const { GET } = await import('@/app/api/setup/status/route'); + + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.setupComplete).toBe(true); + }); + + it('rejects invalid backend mode on setup completion', async () => { + const { POST } = await import('@/app/api/setup/complete/route'); + + const response = await POST({ json: vi.fn().mockResolvedValue({ backendMode: 'invalid' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid or missing backend mode/); + }); + + it('completes setup for Plex mode and returns tokens', async () => { + bcryptMock.hash.mockResolvedValue('hashed'); + prismaMock.user.create.mockResolvedValue({ + id: 'admin-1', + plexId: 'local-admin', + plexUsername: 'admin', + plexEmail: null, + role: 'admin', + avatarUrl: null, + }); + prismaMock.configuration.upsert.mockResolvedValue({}); + prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.bookDateConfig.findFirst.mockResolvedValue(null); + + const { POST } = await import('@/app/api/setup/complete/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + backendMode: 'plex', + admin: { username: 'admin', password: 'pass' }, + plex: { url: 'http://plex', token: 'token', audiobook_library_id: 'lib', machine_identifier: 'machine' }, + prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] }, + downloadClient: { type: 'qbittorrent', url: 'http://qbt', username: 'u', password: 'p' }, + paths: { download_dir: '/downloads', media_dir: '/media' }, + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.accessToken).toBe('access-token'); + expect(prismaMock.configuration.upsert).toHaveBeenCalled(); + }); + + it('completes setup for Audiobookshelf with both auth methods', async () => { + bcryptMock.hash.mockResolvedValue('hashed'); + prismaMock.user.create.mockResolvedValue({ + id: 'admin-2', + plexId: 'local-admin', + plexUsername: 'admin', + plexEmail: null, + role: 'admin', + avatarUrl: null, + }); + prismaMock.configuration.upsert.mockResolvedValue({}); + prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.bookDateConfig.findFirst.mockResolvedValue({ id: 'bookdate-1' }); + prismaMock.bookDateConfig.update.mockResolvedValue({}); + + const { POST } = await import('@/app/api/setup/complete/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + backendMode: 'audiobookshelf', + admin: { username: 'admin', password: 'pass' }, + authMethod: 'both', + audiobookshelf: { + server_url: 'http://abs', + api_token: 'abs-token', + library_id: 'lib', + trigger_scan_after_import: true, + }, + oidc: { + provider_name: 'OIDC', + issuer_url: 'https://issuer', + client_id: 'client-id', + client_secret: 'client-secret', + access_control_method: 'open', + access_group_claim: 'groups', + access_group_value: '', + allowed_emails: '[]', + allowed_usernames: '[]', + admin_claim_enabled: 'true', + admin_claim_name: 'groups', + admin_claim_value: 'admins', + }, + registration: { require_admin_approval: true }, + prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] }, + downloadClient: { + type: 'qbittorrent', + url: 'http://qbt', + username: 'u', + password: 'p', + disableSSLVerify: true, + remotePathMappingEnabled: true, + remotePath: '/remote', + localPath: '/local', + }, + paths: { + download_dir: '/downloads', + media_dir: '/media', + metadata_tagging_enabled: false, + chapter_merging_enabled: true, + }, + bookdate: { provider: 'openai', apiKey: 'bd-key', model: 'gpt-4' }, + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.accessToken).toBe('access-token'); + expect(prismaMock.bookDateConfig.update).toHaveBeenCalled(); + }); + + it('completes setup for Audiobookshelf without admin user', async () => { + prismaMock.configuration.upsert.mockResolvedValue({}); + prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.bookDateConfig.findFirst.mockResolvedValue(null); + + const { POST } = await import('@/app/api/setup/complete/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + backendMode: 'audiobookshelf', + authMethod: 'oidc', + audiobookshelf: { + server_url: 'http://abs', + api_token: 'abs-token', + library_id: 'lib', + }, + oidc: { + provider_name: 'OIDC', + issuer_url: 'https://issuer', + client_id: 'client-id', + client_secret: 'client-secret', + }, + prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] }, + downloadClient: { type: 'qbittorrent', url: 'http://qbt', username: 'u', password: 'p' }, + paths: { download_dir: '/downloads', media_dir: '/media' }, + }), + } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.accessToken).toBeUndefined(); + }); +}); + + diff --git a/tests/api/system.routes.test.ts b/tests/api/system.routes.test.ts new file mode 100644 index 0000000..fdbb1eb --- /dev/null +++ b/tests/api/system.routes.test.ts @@ -0,0 +1,72 @@ +/** + * Component: System API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const schedulerMock = vi.hoisted(() => ({ + start: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/scheduler.service', () => ({ + getSchedulerService: () => schedulerMock, +})); + +describe('System routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns healthy status when database is reachable', async () => { + prismaMock.$queryRaw.mockResolvedValueOnce(1); + const { GET } = await import('@/app/api/health/route'); + + const response = await GET(); + const payload = await response.json(); + + expect(payload.status).toBe('healthy'); + expect(payload.database).toBe('connected'); + }); + + it('returns unhealthy status on database error', async () => { + prismaMock.$queryRaw.mockRejectedValueOnce(new Error('db down')); + const { GET } = await import('@/app/api/health/route'); + + const response = await GET(); + const payload = await response.json(); + + expect(response.status).toBe(503); + expect(payload.status).toBe('unhealthy'); + }); + + it('initializes scheduler on init endpoint', async () => { + const { GET } = await import('@/app/api/init/route'); + + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(schedulerMock.start).toHaveBeenCalled(); + }); + + it('returns version info from environment', async () => { + process.env.APP_VERSION = 'abcdef123456'; + process.env.BUILD_DATE = '2025-01-01'; + + const { GET } = await import('@/app/api/version/route'); + const response = await GET(); + const payload = await response.json(); + + expect(payload.shortCommit).toBe('abcdef1'); + expect(payload.buildDate).toBe('2025-01-01'); + }); +}); + + diff --git a/tests/bookdate/helpers.test.ts b/tests/bookdate/helpers.test.ts new file mode 100644 index 0000000..9ba4317 --- /dev/null +++ b/tests/bookdate/helpers.test.ts @@ -0,0 +1,655 @@ +/** + * Component: BookDate Helper Tests + * Documentation: documentation/features/bookdate-prd.md + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + get: vi.fn(), + getPlexConfig: vi.fn(), +})); +const encryptionMock = vi.hoisted(() => ({ + decrypt: vi.fn(), +})); +const plexMock = vi.hoisted(() => ({ + getServerAccessToken: vi.fn(), + getLibraryContent: vi.fn(), +})); +const findPlexMatchMock = vi.hoisted(() => vi.fn()); +const loggerMock = vi.hoisted(() => ({ + create: vi.fn(), +})); +const audibleState = vi.hoisted(() => ({ + instance: { + search: vi.fn(), + getAudiobookDetails: vi.fn(), + }, + ctor: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => plexMock, +})); + +vi.mock('@/lib/integrations/audible.service', () => ({ + AudibleService: audibleState.ctor, +})); + +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: findPlexMatchMock, +})); + +vi.mock('@/lib/utils/logger', () => ({ + RMABLogger: { + create: loggerMock.create, + }, +})); + +describe('BookDate helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + loggerMock.create.mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }); + audibleState.ctor.mockImplementation(function () { + return audibleState.instance; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns empty library when audiobookshelf has no library id', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue(null); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'rated'); + + expect(result).toEqual([]); + expect(prismaMock.plexLibrary.findMany).not.toHaveBeenCalled(); + }); + + it('maps audiobookshelf cached books without ratings', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue('abs-lib-1'); + prismaMock.user.findUnique.mockResolvedValue({ plexId: 'local-1' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Book', + author: 'Author', + narrator: 'Narr', + plexGuid: 'guid', + plexRatingKey: 'rk', + userRating: '7', + }, + ]); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'full'); + + expect(result).toEqual([ + { + title: 'Book', + author: 'Author', + narrator: 'Narr', + rating: undefined, + }, + ]); + }); + + it('returns rated books for local admin Plex users', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ plexId: 'local-1' }) + .mockResolvedValueOnce({ authToken: 'token', plexId: 'local-1', role: 'admin' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Rated', + author: 'Author', + narrator: null, + plexGuid: 'guid-1', + plexRatingKey: 'rk-1', + userRating: '9', + }, + { + title: 'Unrated', + author: 'Author', + narrator: null, + plexGuid: 'guid-2', + plexRatingKey: 'rk-2', + userRating: null, + }, + ]); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'rated'); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Rated'); + expect(result[0].rating).toBe(9); + }); + + it('returns rated books for Plex users with personal ratings', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ + libraryId: 'plex-lib', + serverUrl: 'http://plex', + machineIdentifier: 'machine', + }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ plexId: 'plex-1' }) + .mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Rated Book', + author: 'Author', + narrator: null, + plexGuid: 'guid-1', + plexRatingKey: 'rk-1', + userRating: null, + }, + { + title: 'Unrated', + author: 'Author', + narrator: null, + plexGuid: 'guid-2', + plexRatingKey: 'rk-2', + userRating: null, + }, + ]); + encryptionMock.decrypt.mockReturnValue('user-token'); + plexMock.getServerAccessToken.mockResolvedValue('server-token'); + plexMock.getLibraryContent.mockResolvedValue([ + { guid: 'guid-1', ratingKey: 'rk-1', userRating: 8 }, + { guid: 'guid-2', ratingKey: 'rk-2' }, + ]); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'rated'); + + expect(result).toEqual([ + { + title: 'Rated Book', + author: 'Author', + narrator: undefined, + rating: 8, + }, + ]); + }); + + it('falls back to cached books when user token is missing', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ plexId: 'plex-2' }) + .mockResolvedValueOnce({ authToken: null, plexId: 'plex-2', role: 'user' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Book', + author: 'Author', + narrator: null, + plexGuid: 'guid', + plexRatingKey: 'rk', + userRating: null, + }, + ]); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'full'); + + expect(result).toEqual([ + { + title: 'Book', + author: 'Author', + narrator: undefined, + rating: undefined, + }, + ]); + }); + + it('returns empty list when Plex library id is missing', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: null }); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'full'); + + expect(result).toEqual([]); + expect(prismaMock.plexLibrary.findMany).not.toHaveBeenCalled(); + }); + + it('falls back to cached books when Plex server URL is missing', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ plexId: 'plex-3' }) + .mockResolvedValueOnce({ authToken: 'token', plexId: 'plex-3', role: 'user' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Book', + author: 'Author', + narrator: null, + plexGuid: 'guid', + plexRatingKey: 'rk', + userRating: null, + }, + ]); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'full'); + + expect(result).toEqual([ + { + title: 'Book', + author: 'Author', + narrator: undefined, + rating: undefined, + }, + ]); + expect(plexMock.getServerAccessToken).not.toHaveBeenCalled(); + }); + + it('uses plaintext token when decryption fails', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ + libraryId: 'plex-lib', + serverUrl: 'http://plex', + machineIdentifier: 'machine', + }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ plexId: 'plex-4' }) + .mockResolvedValueOnce({ authToken: 'plain-token', plexId: 'plex-4', role: 'user' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Rated Book', + author: 'Author', + narrator: null, + plexGuid: 'guid-1', + plexRatingKey: 'rk-1', + userRating: null, + }, + ]); + encryptionMock.decrypt.mockImplementation(() => { + throw new Error('decrypt failed'); + }); + plexMock.getServerAccessToken.mockResolvedValue('server-token'); + plexMock.getLibraryContent.mockResolvedValue([ + { guid: 'guid-1', ratingKey: 'rk-1', userRating: 7 }, + ]); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'rated'); + + expect(result[0].rating).toBe(7); + expect(plexMock.getServerAccessToken).toHaveBeenCalledWith('machine', 'plain-token'); + }); + + it('returns cached books when machine identifier is missing', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ + libraryId: 'plex-lib', + serverUrl: 'http://plex', + machineIdentifier: null, + }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ plexId: 'plex-5' }) + .mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-5', role: 'user' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Book', + author: 'Author', + narrator: null, + plexGuid: 'guid', + plexRatingKey: 'rk', + userRating: null, + }, + ]); + encryptionMock.decrypt.mockReturnValue('user-token'); + + const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers'); + const result = await getUserLibraryBooks('user-1', 'full'); + + expect(result).toEqual([ + { + title: 'Book', + author: 'Author', + narrator: undefined, + rating: undefined, + }, + ]); + expect(plexMock.getServerAccessToken).not.toHaveBeenCalled(); + }); + + it('builds recent swipe history from prioritized swipes', async () => { + const now = new Date(); + const older = new Date(now.getTime() - 1000); + + prismaMock.bookDateSwipe.findMany + .mockResolvedValueOnce([ + { + bookTitle: 'Latest', + bookAuthor: 'Author', + action: 'right', + markedAsKnown: false, + createdAt: now, + }, + ]) + .mockResolvedValueOnce([ + { + bookTitle: 'Older', + bookAuthor: 'Author', + action: 'up', + markedAsKnown: false, + createdAt: older, + }, + ]); + + const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers'); + const result = await getUserRecentSwipes('user-1', 2); + + expect(result).toEqual([ + { title: 'Latest', author: 'Author', action: 'right', markedAsKnown: false }, + { title: 'Older', author: 'Author', action: 'up', markedAsKnown: false }, + ]); + expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(2); + }); + + it('skips dismiss lookup when limit is filled by non-dismiss swipes', async () => { + prismaMock.bookDateSwipe.findMany.mockResolvedValueOnce([ + { + bookTitle: 'Recent', + bookAuthor: 'Author', + action: 'right', + markedAsKnown: true, + createdAt: new Date(), + }, + ]); + + const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers'); + const result = await getUserRecentSwipes('user-1', 1); + + expect(result).toEqual([ + { title: 'Recent', author: 'Author', action: 'right', markedAsKnown: true }, + ]); + expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(1); + }); + + it('builds AI prompt with mapped swipe actions', async () => { + const now = new Date(); + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue('abs-lib-1'); + prismaMock.user.findUnique.mockResolvedValue({ plexId: 'local-1' }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + title: 'Lib', + author: 'Author', + narrator: null, + plexGuid: 'guid', + plexRatingKey: 'rk', + userRating: '8', + }, + ]); + prismaMock.bookDateSwipe.findMany + .mockResolvedValueOnce([ + { + bookTitle: 'Known', + bookAuthor: 'Author', + action: 'right', + markedAsKnown: true, + createdAt: now, + }, + { + bookTitle: 'Requested', + bookAuthor: 'Author', + action: 'right', + markedAsKnown: false, + createdAt: new Date(now.getTime() - 1000), + }, + { + bookTitle: 'Rejected', + bookAuthor: 'Author', + action: 'left', + markedAsKnown: false, + createdAt: new Date(now.getTime() - 2000), + }, + ]) + .mockResolvedValueOnce([ + { + bookTitle: 'Dismissed', + bookAuthor: 'Author', + action: 'up', + markedAsKnown: false, + createdAt: new Date(now.getTime() - 3000), + }, + ]); + + const { buildAIPrompt } = await import('@/lib/bookdate/helpers'); + const prompt = await buildAIPrompt('user-1', { libraryScope: 'full', customPrompt: 'prefs' }); + const parsed = JSON.parse(prompt); + + expect(parsed.user_context.library_books).toHaveLength(1); + expect(parsed.user_context.swipe_history).toEqual([ + { title: 'Known', author: 'Author', user_action: 'marked_as_liked' }, + { title: 'Requested', author: 'Author', user_action: 'requested' }, + { title: 'Rejected', author: 'Author', user_action: 'rejected' }, + { title: 'Dismissed', author: 'Author', user_action: 'dismissed' }, + ]); + }); + + it('returns cached Audnexus matches without fetching Audible', async () => { + prismaMock.audibleCache.findFirst.mockResolvedValue({ + asin: 'ASIN1', + title: 'Title', + author: 'Author', + narrator: 'Narr', + rating: '4.5', + description: 'Desc', + coverArtUrl: 'cover', + }); + + const { matchToAudnexus } = await import('@/lib/bookdate/helpers'); + const result = await matchToAudnexus('Title', 'Author'); + + expect(result?.asin).toBe('ASIN1'); + expect(result?.rating).toBe(4.5); + expect(audibleState.ctor).not.toHaveBeenCalled(); + }); + + it('returns null when Audible search finds no results', async () => { + prismaMock.audibleCache.findFirst.mockResolvedValue(null); + audibleState.instance.search.mockResolvedValue({ results: [] }); + + const { matchToAudnexus } = await import('@/lib/bookdate/helpers'); + const result = await matchToAudnexus('Missing', 'Author'); + + expect(result).toBeNull(); + expect(audibleState.instance.search).toHaveBeenCalled(); + }); + + it('returns null when Audible details are unavailable', async () => { + prismaMock.audibleCache.findFirst.mockResolvedValue(null); + audibleState.instance.search.mockResolvedValue({ + results: [{ asin: 'ASIN2', title: 'Title', author: 'Author' }], + }); + audibleState.instance.getAudiobookDetails.mockResolvedValue(null); + + const { matchToAudnexus } = await import('@/lib/bookdate/helpers'); + const result = await matchToAudnexus('Title', 'Author'); + + expect(result).toBeNull(); + }); + + it('returns Audnexus details for successful Audible matches', async () => { + prismaMock.audibleCache.findFirst.mockResolvedValue(null); + audibleState.instance.search.mockResolvedValue({ + results: [{ asin: 'ASIN3', title: 'Title', author: 'Author' }], + }); + audibleState.instance.getAudiobookDetails.mockResolvedValue({ + asin: 'ASIN3', + title: 'Title', + author: 'Author', + narrator: 'Narr', + rating: 4.2, + description: 'Desc', + coverArtUrl: 'cover', + }); + + const { matchToAudnexus } = await import('@/lib/bookdate/helpers'); + const result = await matchToAudnexus('Title', 'Author'); + + expect(result).toEqual({ + asin: 'ASIN3', + title: 'Title', + author: 'Author', + narrator: 'Narr', + rating: 4.2, + description: 'Desc', + coverUrl: 'cover', + }); + }); + + it('checks library matches using the Plex matcher', async () => { + const { isInLibrary } = await import('@/lib/bookdate/helpers'); + + findPlexMatchMock.mockResolvedValueOnce({ title: 'Match' }); + await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(true); + + findPlexMatchMock.mockResolvedValueOnce(null); + await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(false); + }); + + it('checks existing requests and swipes', async () => { + const { isAlreadyRequested, isAlreadySwiped } = await import('@/lib/bookdate/helpers'); + + prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-1' }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({ id: 'swipe-1' }); + prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce(null); + + await expect(isAlreadyRequested('user-1', 'ASIN1')).resolves.toBe(true); + await expect(isAlreadyRequested('user-1', 'ASIN1')).resolves.toBe(false); + await expect(isAlreadySwiped('user-1', 'Title', 'Author')).resolves.toBe(true); + await expect(isAlreadySwiped('user-1', 'Title', 'Author')).resolves.toBe(false); + }); + + it('throws on invalid AI provider', async () => { + const { callAI } = await import('@/lib/bookdate/helpers'); + + await expect(callAI('invalid', 'model', 'key', '{}')).rejects.toThrow('Invalid provider'); + }); + + it('requires a base URL for custom providers', async () => { + const { callAI } = await import('@/lib/bookdate/helpers'); + + await expect(callAI('custom', 'model', 'key', '{}', null)).rejects.toThrow('Base URL is required'); + }); + + it('calls OpenAI and parses JSON recommendations', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: '{\"recommendations\":[]}' } }], + }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + encryptionMock.decrypt.mockReturnValue('api-key'); + + const { callAI } = await import('@/lib/bookdate/helpers'); + const result = await callAI('openai', 'model', 'enc-key', '{}'); + + expect(result.recommendations).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.openai.com/v1/chat/completions', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('calls Claude and strips markdown from JSON', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + content: [{ text: '```json\n{\"recommendations\":[]}\n```' }], + }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + encryptionMock.decrypt.mockReturnValue('api-key'); + + const { callAI } = await import('@/lib/bookdate/helpers'); + const result = await callAI('claude', 'model', 'enc-key', '{}'); + + expect(result.recommendations).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/messages', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('calls custom provider and parses direct JSON', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: '{\"recommendations\":[]}' } }], + }), + }); + vi.stubGlobal('fetch', fetchMock); + encryptionMock.decrypt.mockReturnValue('api-key'); + + const { callAI } = await import('@/lib/bookdate/helpers'); + const result = await callAI('custom', 'model', 'enc-key', '{}', 'http://custom/'); + + expect(result.recommendations).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + 'http://custom/chat/completions', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('retries custom providers without structured output', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 400, + text: vi.fn().mockResolvedValue('response_format unsupported'), + }) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + choices: [{ message: { content: '{\"recommendations\":[]}' } }], + }), + }); + vi.stubGlobal('fetch', fetchMock); + encryptionMock.decrypt.mockImplementation(() => { + throw new Error('decrypt failed'); + }); + + const { callAI } = await import('@/lib/bookdate/helpers'); + const result = await callAI('custom', 'model', 'enc-key', '{}', 'http://custom'); + + expect(result.recommendations).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/components/admin-settings-indexers.test.tsx b/tests/components/admin-settings-indexers.test.tsx new file mode 100644 index 0000000..490c226 --- /dev/null +++ b/tests/components/admin-settings-indexers.test.tsx @@ -0,0 +1,253 @@ +/** + * Component: Admin Settings - Indexers Tab Auto-load Test + * Documentation: documentation/testing.md + * + * This test verifies that indexers are automatically loaded when: + * 1. The prowlarr tab becomes active + * 2. Prowlarr URL and API key are configured + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { IndexersTab } from '@/app/admin/settings/tabs/IndexersTab'; +import type { Settings, SavedIndexerConfig } from '@/app/admin/settings/lib/types'; +import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm'; + +// Mock fetchWithAuth +const mockFetchWithAuth = vi.fn(); +vi.mock('@/lib/utils/api', () => ({ + fetchWithAuth: (url: string, options?: any) => mockFetchWithAuth(url, options), +})); + +// Mock child components to simplify testing +vi.mock('@/components/admin/indexers/IndexerManagement', () => ({ + IndexerManagement: ({ initialIndexers }: { initialIndexers: SavedIndexerConfig[] }) => ( +
+ {initialIndexers.length > 0 ? ( +
+ {initialIndexers.length} indexers loaded +
+ ) : ( +
No indexers
+ )} +
+ ), +})); + +vi.mock('@/components/admin/FlagConfigRow', () => ({ + FlagConfigRow: () =>
Flag Config
, +})); + +vi.mock('@/components/ui/Button', () => ({ + Button: ({ children, onClick, loading, disabled, ...props }: any) => ( + + ), +})); + +vi.mock('@/components/ui/Input', () => ({ + Input: (props: any) => , +})); + +describe('IndexersTab - Auto-load Indexers on Tab Activation', () => { + const mockSettings: Settings = { + backendMode: 'plex', + hasLocalUsers: false, + audibleRegion: 'us', + plex: { + url: 'http://plex.local:32400', + token: 'test-token', + libraryId: '1', + triggerScanAfterImport: false, + }, + audiobookshelf: { + serverUrl: '', + apiToken: '', + libraryId: '', + triggerScanAfterImport: false, + }, + oidc: { + enabled: false, + providerName: '', + issuerUrl: '', + clientId: '', + clientSecret: '', + accessControlMethod: 'open', + accessGroupClaim: '', + accessGroupValue: '', + allowedEmails: '', + allowedUsernames: '', + adminClaimEnabled: false, + adminClaimName: '', + adminClaimValue: '', + }, + registration: { + enabled: false, + requireAdminApproval: false, + }, + prowlarr: { + url: 'http://prowlarr.local:9696', + apiKey: 'test-api-key', + }, + downloadClient: { + type: 'qbittorrent', + url: 'http://localhost:8080', + username: 'admin', + password: 'password', + disableSSLVerify: false, + remotePathMappingEnabled: false, + remotePath: '', + localPath: '', + }, + paths: { + downloadDir: '/downloads', + mediaDir: '/media', + metadataTaggingEnabled: true, + chapterMergingEnabled: true, + }, + ebook: { + enabled: false, + preferredFormat: 'epub', + baseUrl: 'https://annas-archive.li', + flaresolverrUrl: '', + }, + }; + + const mockIndexers: SavedIndexerConfig[] = [ + { + id: 1, + name: 'AudioBook Bay', + priority: 10, + seedingTimeMinutes: 4320, + rssEnabled: true, + categories: [3030], + }, + { + id: 2, + name: 'MyAnonaMouse', + priority: 15, + seedingTimeMinutes: 10080, + rssEnabled: false, + categories: [3030], + }, + ]; + + const mockFlagConfigs: IndexerFlagConfig[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should display empty indexers when no indexers are loaded', () => { + const { container } = render( + + ); + + expect(screen.getByTestId('indexers-empty')).toBeInTheDocument(); + }); + + it('should display indexers when indexers prop contains data', () => { + render( + + ); + + expect(screen.getByTestId('indexers-loaded')).toBeInTheDocument(); + expect(screen.getByText('2 indexers loaded')).toBeInTheDocument(); + }); + + it('BUG: should automatically fetch indexers when onRefreshIndexers is called on mount', async () => { + const mockOnRefreshIndexers = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + // The bug: onRefreshIndexers should be called automatically when the component mounts + // IF prowlarr URL and API key are configured + await waitFor(() => { + expect(mockOnRefreshIndexers).toHaveBeenCalledTimes(1); + }, { timeout: 1000 }); + }); + + it('should NOT auto-fetch indexers if prowlarr URL is missing', async () => { + const mockOnRefreshIndexers = vi.fn().mockResolvedValue(undefined); + const settingsWithoutUrl = { + ...mockSettings, + prowlarr: { url: '', apiKey: 'test-api-key' }, + }; + + render( + + ); + + // Should NOT call onRefreshIndexers because URL is missing + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mockOnRefreshIndexers).not.toHaveBeenCalled(); + }); + + it('should NOT auto-fetch indexers if prowlarr API key is missing', async () => { + const mockOnRefreshIndexers = vi.fn().mockResolvedValue(undefined); + const settingsWithoutApiKey = { + ...mockSettings, + prowlarr: { url: 'http://prowlarr.local:9696', apiKey: '' }, + }; + + render( + + ); + + // Should NOT call onRefreshIndexers because API key is missing + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(mockOnRefreshIndexers).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/helpers/job-queue.ts b/tests/helpers/job-queue.ts new file mode 100644 index 0000000..19fd7be --- /dev/null +++ b/tests/helpers/job-queue.ts @@ -0,0 +1,21 @@ +/** + * Component: Job Queue Mock Factory + * Documentation: documentation/backend/services/jobs.md + */ + +import { vi } from 'vitest'; + +export const createJobQueueMock = () => ({ + addSearchJob: vi.fn(), + addDownloadJob: vi.fn(), + addMonitorJob: vi.fn(), + addOrganizeJob: vi.fn(), + addPlexScanJob: vi.fn(), + addPlexMatchJob: vi.fn(), + addPlexRecentlyAddedJob: vi.fn(), + addMonitorRssFeedsJob: vi.fn(), + addAudibleRefreshJob: vi.fn(), + addRetryMissingTorrentsJob: vi.fn(), + addRetryFailedImportsJob: vi.fn(), + addCleanupSeededTorrentsJob: vi.fn(), +}); diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts new file mode 100644 index 0000000..d61e16a --- /dev/null +++ b/tests/helpers/prisma.ts @@ -0,0 +1,50 @@ +/** + * Component: Prisma Mock Factory + * Documentation: documentation/backend/database.md + */ + +import { vi } from 'vitest'; + +type PrismaModelMock = { + findMany: ReturnType; + findFirst: ReturnType; + findUnique: ReturnType; + create: ReturnType; + update: ReturnType; + updateMany: ReturnType; + upsert: ReturnType; + delete: ReturnType; + deleteMany: ReturnType; + count: ReturnType; +}; + +const createModelMock = (): PrismaModelMock => ({ + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(() => Promise.resolve({})), + update: vi.fn(), + updateMany: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn(), + count: vi.fn(), +}); + +export const createPrismaMock = () => ({ + configuration: createModelMock(), + user: createModelMock(), + request: createModelMock(), + audiobook: createModelMock(), + downloadHistory: createModelMock(), + plexLibrary: createModelMock(), + audibleCache: createModelMock(), + job: createModelMock(), + jobEvent: createModelMock(), + scheduledJob: createModelMock(), + bookDateConfig: createModelMock(), + bookDateRecommendation: createModelMock(), + bookDateSwipe: createModelMock(), + $queryRaw: vi.fn(), + $disconnect: vi.fn(), +}); diff --git a/tests/integrations/audible.service.test.ts b/tests/integrations/audible.service.test.ts new file mode 100644 index 0000000..a3cdef5 --- /dev/null +++ b/tests/integrations/audible.service.test.ts @@ -0,0 +1,485 @@ +/** + * Component: Audible Integration Service Tests + * Documentation: documentation/integrations/audible.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AudibleService } from '@/lib/integrations/audible.service'; +import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible'; + +const clientMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const axiosMock = vi.hoisted(() => ({ + create: vi.fn(() => clientMock), + get: vi.fn(), +})); + +const configServiceMock = vi.hoisted(() => ({ + getAudibleRegion: vi.fn(), +})); + +const fsCoreMock = vi.hoisted(() => ({ + writeFileSync: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('fs', () => fsCoreMock); + +describe('AudibleService', () => { + beforeEach(() => { + vi.clearAllMocks(); + clientMock.get.mockReset(); + axiosMock.get.mockReset(); + configServiceMock.getAudibleRegion.mockReset(); + }); + + const buildListHtml = (count: number, startIndex: number = 0) => + Array.from({ length: count }, (_, i) => { + const asin = `B${String(i + 1 + startIndex).padStart(9, '0')}`; + return ` +
+
  • +

    Title ${i + 1}

    + By: Author ${i + 1} + Narrated by: Narrator ${i + 1} + + 4.${i} out of 5 stars +
    + `; + }).join(''); + + it('parses search results from HTML', async () => { + const html = ` +
    +
  • +

    The Test Book

    + Author Name + Narrated by: Narrator Name + + Length: 5 hrs and 30 mins + 4.5 out of 5 stars +
    +
    1-20 of 55 results
    + `; + + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const result = await service.search('test', 1); + + expect(result.results).toHaveLength(1); + expect(result.results[0].asin).toBe('B000123456'); + expect(result.results[0].title).toBe('The Test Book'); + expect(result.results[0].author).toBe('Author Name'); + expect(result.results[0].narrator).toBe('Narrator Name'); + expect(result.results[0].durationMinutes).toBe(330); + expect(result.results[0].rating).toBe(4.5); + expect(result.results[0].coverArtUrl).toContain('_SL500_'); + expect(result.totalResults).toBe(55); + expect(result.hasMore).toBe(true); + }); + + it('reinitializes when the configured region changes', async () => { + const html = `
    0 results
    `; + configServiceMock.getAudibleRegion + .mockResolvedValueOnce('us') + .mockResolvedValueOnce('uk') + .mockResolvedValueOnce('uk'); + clientMock.get.mockResolvedValue({ data: html }); + + const service = new AudibleService(); + await service.search('test', 1); + await service.search('test', 1); + + expect(axiosMock.create).toHaveBeenCalledTimes(2); + expect(axiosMock.create.mock.calls[1][0].baseURL).toBe(AUDIBLE_REGIONS.uk.baseUrl); + }); + + it('reinitializes when forced manually', async () => { + const html = `
    0 results
    `; + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockResolvedValue({ data: html }); + + const service = new AudibleService(); + await service.search('test', 1); + service.forceReinitialize(); + await service.search('test', 1); + + expect(axiosMock.create).toHaveBeenCalledTimes(2); + }); + + it('falls back to default region when initialization fails', async () => { + const html = `
    0 results
    `; + configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail')); + clientMock.get.mockResolvedValue({ data: html }); + + const service = new AudibleService(); + const result = await service.search('fallback', 1); + + expect(result.totalResults).toBe(0); + expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].baseUrl); + }); + + it('paginates new releases and respects delays between pages', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get + .mockResolvedValueOnce({ data: buildListHtml(10, 0) }) + .mockResolvedValueOnce({ data: buildListHtml(5, 10) }); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + const results = await service.getNewReleases(25); + + expect(results).toHaveLength(15); + expect(delaySpy).toHaveBeenCalledTimes(1); + }); + + it('parses popular audiobooks and stops early when fewer results are found', async () => { + const html = ` +
    +
  • +

    Popular One

    + By: Author One + Narrated by: Narrator One + + 4.2 out of 5 stars +
    + `; + + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const results = await service.getPopularAudiobooks(1); + + expect(results).toHaveLength(1); + expect(results[0].asin).toBe('B000111111'); + expect(results[0].title).toBe('Popular One'); + }); + + it('skips duplicate ASINs when parsing new releases', async () => { + const html = ` +
    +
  • +

    Title One

    +
    +
    +
  • +

    Title Two

    +
    + `; + + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const results = await service.getNewReleases(20); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Title One'); + }); + + it('returns empty search results on failures', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockRejectedValue(new Error('nope')); + + const service = new AudibleService(); + const result = await service.search('oops', 1); + + expect(result.results).toEqual([]); + expect(result.hasMore).toBe(false); + }); + + it('returns audiobooks from Audnexus when available', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockResolvedValueOnce({ + data: { + title: 'Audnexus Book', + authors: [{ name: 'Author A' }], + narrators: [{ name: 'Narrator A' }], + description: 'Desc', + image: 'https://images.example.com/cover._SL200_.jpg', + runtimeLengthMin: '300', + genres: ['Fiction'], + rating: '4.7', + }, + }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000AAAAAA'); + + expect(details?.title).toBe('Audnexus Book'); + expect(details?.author).toBe('Author A'); + expect(details?.durationMinutes).toBe(300); + expect(details?.coverArtUrl).toContain('_SL500_'); + }); + + it('scrapes details from HTML when Audnexus fails', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockRejectedValueOnce({ response: { status: 500 }, message: 'boom' }); + + const html = ` + +
    +

    HTML Title

    +
  • By: HTML Author
  • +
  • Narrated by: HTML Narrator
  • +
  • Length: 2 hrs and 5 mins
  • +
  • Release date: Jan 2, 2022
  • + 4.8 out of 5 stars + +
    + This is a long description for testing the Audible HTML parsing logic. +
    + Fiction +
    + `; + + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000CCCCCC'); + + expect(details?.title).toBe('HTML Title'); + expect(details?.author).toBe('HTML Author'); + expect(details?.narrator).toBe('HTML Narrator'); + expect(details?.durationMinutes).toBe(125); + expect(details?.rating).toBe(4.8); + expect(details?.releaseDate).toBe('Jan 2, 2022'); + expect(details?.coverArtUrl).toContain('_SL500_'); + expect(details?.genres).toContain('Fiction'); + }); + + it('falls back to Audible scraping when Audnexus returns 404', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + const html = ` + + `; + + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000BBBBBB'); + + expect(details?.title).toBe('Fallback Book'); + expect(details?.author).toBe('Fallback Author'); + expect(details?.durationMinutes).toBe(510); + }); + + it('returns runtime from Audnexus data', async () => { + axiosMock.get.mockResolvedValue({ data: { runtimeLengthMin: '480' } }); + + const service = new AudibleService(); + const runtime = await service.getRuntime('B000123456'); + + expect(runtime).toBe(480); + }); + + it('returns null runtime when Audnexus returns 404', async () => { + axiosMock.get.mockRejectedValue({ response: { status: 404 }, message: 'Not found' }); + + const service = new AudibleService(); + const runtime = await service.getRuntime('B000404404'); + + expect(runtime).toBeNull(); + }); + + it('returns null runtime when Audnexus errors unexpectedly', async () => { + axiosMock.get.mockRejectedValue({ response: { status: 500 }, message: 'Boom' }); + + const service = new AudibleService(); + const runtime = await service.getRuntime('B000500500'); + + expect(runtime).toBeNull(); + }); + + it('parses runtime strings into minutes', () => { + const service = new AudibleService(); + const parseRuntime = (service as any).parseRuntime.bind(service); + + expect(parseRuntime('Length: 1 hr and 5 mins')).toBe(65); + expect(parseRuntime('Length: 45 mins')).toBe(45); + expect(parseRuntime('')).toBeUndefined(); + }); + + it('does not reinitialize when the region is unchanged', async () => { + const html = `
    0 results
    `; + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockResolvedValue({ data: html }); + + const service = new AudibleService(); + await service.search('test', 1); + await service.search('test', 1); + + expect(axiosMock.create).toHaveBeenCalledTimes(1); + }); + + it('paginates popular audiobooks across pages', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get + .mockResolvedValueOnce({ data: buildListHtml(10, 0) }) + .mockResolvedValueOnce({ data: buildListHtml(10, 10) }); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + const results = await service.getPopularAudiobooks(25); + + expect(results).toHaveLength(20); + expect(delaySpy).toHaveBeenCalledTimes(1); + }); + + it('returns empty popular audiobooks on errors', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockRejectedValue(new Error('boom')); + + const service = new AudibleService(); + const results = await service.getPopularAudiobooks(5); + + expect(results).toEqual([]); + }); + + it('returns empty new releases on errors', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + clientMock.get.mockRejectedValue(new Error('boom')); + + const service = new AudibleService(); + const results = await service.getNewReleases(5); + + expect(results).toEqual([]); + }); + + it('returns null when getAudiobookDetails throws', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + + const service = new AudibleService(); + vi.spyOn(service as any, 'fetchFromAudnexus').mockResolvedValue(null); + vi.spyOn(service as any, 'scrapeAudibleDetails').mockRejectedValue(new Error('boom')); + + const result = await service.getAudiobookDetails('B000TEST'); + + expect(result).toBeNull(); + }); + + it('writes debug HTML in development mode', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + clientMock.get.mockResolvedValueOnce({ + data: '

    Dev Book

    ', + }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000DEV'); + + expect(details?.title).toBe('Dev Book'); + + process.env.NODE_ENV = originalEnv; + }); + + it('parses JSON-LD author and narrator arrays', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + const html = ` + + `; + + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000ARRAY'); + + expect(details?.author).toBe('Author One, Author Two'); + expect(details?.narrator).toBe('Narrator One, Narrator Two'); + }); + + it('falls back to author and narrator links when labels are missing', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + const html = ` + + `; + + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000LINKS'); + + expect(details?.author).toBe('Author One'); + expect(details?.narrator).toBe('Narrator One'); + }); + + it('extracts descriptions from fallback paragraphs', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + const html = ` +

    This description is intentionally long enough to satisfy the minimum length requirement for parsing.

    + `; + + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000DESC'); + + expect(details?.description).toContain('intentionally long enough'); + }); + + it('detects runtime from generic duration text', async () => { + configServiceMock.getAudibleRegion.mockResolvedValue('us'); + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + const html = ` + 10 hr 2 min + `; + + clientMock.get.mockResolvedValueOnce({ data: html }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000TIME'); + + expect(details?.durationMinutes).toBe(602); + }); +}); diff --git a/tests/integrations/plex.service.test.ts b/tests/integrations/plex.service.test.ts new file mode 100644 index 0000000..0866ac1 --- /dev/null +++ b/tests/integrations/plex.service.test.ts @@ -0,0 +1,709 @@ +/** + * Component: Plex Integration Service Tests + * Documentation: documentation/integrations/plex.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlexService } from '@/lib/integrations/plex.service'; + +const clientMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +const axiosMock = vi.hoisted(() => ({ + create: vi.fn(() => clientMock), +})); + +const parseStringPromiseMock = vi.hoisted(() => vi.fn()); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('xml2js', () => ({ + parseStringPromise: parseStringPromiseMock, +})); + +describe('PlexService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('requests a PIN for OAuth', async () => { + clientMock.post.mockResolvedValue({ data: { id: 123, code: 'CODE' } }); + + const service = new PlexService(); + const pin = await service.requestPin(); + + expect(pin).toEqual({ id: 123, code: 'CODE' }); + }); + + it('throws when PIN request fails', async () => { + clientMock.post.mockRejectedValue(new Error('fail')); + + const service = new PlexService(); + + await expect(service.requestPin()).rejects.toThrow('Failed to request authentication PIN from Plex'); + }); + + it('returns null when PIN check fails', async () => { + clientMock.get.mockRejectedValue(new Error('fail')); + + const service = new PlexService(); + const token = await service.checkPin(123); + + expect(token).toBeNull(); + }); + + it('returns auth token when PIN is authorized', async () => { + clientMock.get.mockResolvedValue({ data: { authToken: 'plex-token' } }); + + const service = new PlexService(); + const token = await service.checkPin(456); + + expect(token).toBe('plex-token'); + }); + + it('parses user info from XML responses', async () => { + clientMock.get.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ + user: { + $: { id: '1', username: 'user', email: 'e@example.com', thumb: '/t' }, + }, + }); + + const service = new PlexService(); + const user = await service.getUserInfo('token'); + + expect(user).toEqual({ + id: 1, + username: 'user', + email: 'e@example.com', + thumb: '/t', + authToken: 'token', + }); + }); + + it('parses user info from JSON responses and falls back to title', async () => { + clientMock.get.mockResolvedValue({ + data: { id: '2', title: 'TitleUser', email: 't@example.com', thumb: '/t' }, + }); + + const service = new PlexService(); + const user = await service.getUserInfo('token'); + + expect(user.username).toBe('TitleUser'); + expect(user.id).toBe(2); + }); + + it('throws for unexpected XML user structure', async () => { + clientMock.get.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ notUser: {} }); + + const service = new PlexService(); + + await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected XML structure'); + }); + + it('throws for unexpected response formats', async () => { + clientMock.get.mockResolvedValue({ data: 42 }); + + const service = new PlexService(); + + await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected response format from Plex'); + }); + + it('throws when user info is missing required fields', async () => { + clientMock.get.mockResolvedValue({ data: { username: 'user' } }); + + const service = new PlexService(); + + await expect(service.getUserInfo('token')).rejects.toThrow('User ID missing'); + }); + + it('throws when username is missing from user info', async () => { + clientMock.get.mockResolvedValue({ data: { id: '3' } }); + + const service = new PlexService(); + + await expect(service.getUserInfo('token')).rejects.toThrow('Username missing'); + }); + + it('returns OAuth URLs with pinId', () => { + const service = new PlexService(); + const url = service.getOAuthUrl('CODE', 42, 'http://app/callback'); + + expect(url).toContain('CODE'); + expect(url).toContain('pinId%3D42'); + }); + + it('tests connections and parses MediaContainer responses', async () => { + clientMock.get.mockResolvedValue({ + data: { + MediaContainer: { + machineIdentifier: 'machine', + version: '1.0.0', + platform: 'Plex', + }, + }, + }); + + const service = new PlexService(); + const result = await service.testConnection('http://plex', 'token'); + + expect(result.success).toBe(true); + expect(result.info?.machineIdentifier).toBe('machine'); + }); + + it('tests connections from XML identity responses', async () => { + clientMock.get.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ + MediaContainer: { $: { machineIdentifier: 'm1', version: '1.2.3', platform: 'Linux', platformVersion: '5' } }, + }); + + const service = new PlexService(); + const result = await service.testConnection('http://plex', 'token'); + + expect(result.success).toBe(true); + expect(result.info?.platform).toBe('Linux'); + }); + + it('finds server access tokens in plex resources', async () => { + clientMock.get.mockResolvedValue({ + data: [ + { clientIdentifier: 'machine', accessToken: 'server-token' }, + ], + }); + + const service = new PlexService(); + const token = await service.getServerAccessToken('machine', 'user-token'); + + expect(token).toBe('server-token'); + }); + + it('returns null when server resource is missing', async () => { + clientMock.get.mockResolvedValue({ data: [{ clientIdentifier: 'other', accessToken: 'x' }] }); + + const service = new PlexService(); + const token = await service.getServerAccessToken('machine', 'user-token'); + + expect(token).toBeNull(); + }); + + it('returns null when server access token is missing', async () => { + clientMock.get.mockResolvedValue({ + data: [{ clientIdentifier: 'machine', accessToken: null }], + }); + + const service = new PlexService(); + const token = await service.getServerAccessToken('machine', 'user-token'); + + expect(token).toBeNull(); + }); + + it('verifies server access for matching resources', async () => { + clientMock.get.mockResolvedValue({ + data: [ + { clientIdentifier: 'machine', provides: 'server', name: 'Plex' }, + ], + }); + + const service = new PlexService(); + const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token'); + + expect(hasAccess).toBe(true); + }); + + it('returns false when server access is not available', async () => { + clientMock.get.mockResolvedValue({ + data: [{ clientIdentifier: 'other', provides: 'client', name: 'Plex' }], + }); + + const service = new PlexService(); + const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token'); + + expect(hasAccess).toBe(false); + }); + + it('returns false when verifying server access errors', async () => { + clientMock.get.mockRejectedValue({ response: { status: 500, data: 'oops' }, message: 'boom' }); + + const service = new PlexService(); + const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token'); + + expect(hasAccess).toBe(false); + }); + + it('parses libraries from XML responses', async () => { + clientMock.get.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ + MediaContainer: { + Directory: [ + { + $: { + key: '1', + title: 'Books', + type: 'artist', + language: 'en', + scanner: 'scanner', + agent: 'agent', + }, + Location: [{ $: { path: '/data' } }], + }, + ], + }, + }); + + const service = new PlexService(); + const libs = await service.getLibraries('http://plex', 'token'); + + expect(libs).toEqual([ + { + id: '1', + title: 'Books', + type: 'artist', + language: 'en', + scanner: 'scanner', + agent: 'agent', + locations: ['/data'], + }, + ]); + }); + + it('parses libraries from JSON responses', async () => { + clientMock.get.mockResolvedValue({ + data: { + MediaContainer: { + Directory: [ + { + key: '2', + title: 'Library', + type: 'artist', + language: 'en', + scanner: 'scanner', + agent: 'agent', + Location: [{ path: '/media' }], + }, + ], + }, + }, + }); + + const service = new PlexService(); + const libs = await service.getLibraries('http://plex', 'token'); + + expect(libs).toEqual([ + { + id: '2', + title: 'Library', + type: 'artist', + language: 'en', + scanner: 'scanner', + agent: 'agent', + locations: ['/media'], + }, + ]); + }); + + it('returns null metadata for unauthorized users', async () => { + clientMock.get.mockRejectedValue({ response: { status: 401 } }); + + const service = new PlexService(); + const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1'); + + expect(meta).toBeNull(); + }); + + it('returns null metadata when item is missing', async () => { + clientMock.get.mockRejectedValue({ response: { status: 404 } }); + + const service = new PlexService(); + const meta = await service.getItemMetadata('http://plex', 'token', 'rk-2'); + + expect(meta).toBeNull(); + }); + + it('parses metadata from XML responses', async () => { + clientMock.get.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ + MediaContainer: { + Metadata: [{ $: { userRating: '9' } }], + }, + }); + + const service = new PlexService(); + const meta = await service.getItemMetadata('http://plex', 'token', 'rk-3'); + + expect(meta?.userRating).toBe(9); + }); + + it('returns user ratings when metadata exists', async () => { + clientMock.get.mockResolvedValue({ + data: { MediaContainer: { Metadata: [{ userRating: '7.5' }] } }, + }); + + const service = new PlexService(); + const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1'); + + expect(meta?.userRating).toBe(7.5); + }); + + it('searches library content from XML responses', async () => { + clientMock.get.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ + MediaContainer: { + Metadata: [ + { + $: { + ratingKey: 'rk-1', + guid: 'guid-1', + title: 'Title', + grandparentTitle: 'Author', + summary: 'Summary', + thumb: '/thumb', + addedAt: '1', + updatedAt: '2', + duration: '1000', + }, + }, + ], + }, + }); + + const service = new PlexService(); + const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title'); + + expect(results[0].ratingKey).toBe('rk-1'); + expect(results[0].author).toBe('Author'); + }); + + it('returns empty arrays when search fails', async () => { + clientMock.get.mockRejectedValue(new Error('search fail')); + + const service = new PlexService(); + const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title'); + + expect(results).toEqual([]); + }); + + it('returns empty arrays when recently added data is not a list', async () => { + clientMock.get.mockResolvedValue({ + data: { MediaContainer: { Metadata: {} } }, + }); + + const service = new PlexService(); + const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 10); + + expect(results).toEqual([]); + }); + + it('returns empty arrays when library content data is not a list', async () => { + clientMock.get.mockResolvedValue({ + data: { MediaContainer: { Metadata: {} } }, + }); + + const service = new PlexService(); + const results = await service.getLibraryContent('http://plex', 'token', 'lib-1'); + + expect(results).toEqual([]); + }); + + it('parses library content from XML responses', async () => { + clientMock.get.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ + MediaContainer: { + Metadata: [ + { + ratingKey: 'rk-1', + guid: 'guid-1', + title: 'Title', + parentTitle: 'Author', + writer: 'Narr', + duration: '1000', + year: '2020', + summary: 'Summary', + thumb: '/thumb', + addedAt: '1', + updatedAt: '2', + userRating: '7', + }, + ], + }, + }); + + const service = new PlexService(); + const results = await service.getLibraryContent('http://plex', 'token', 'lib-1'); + + expect(results).toEqual([ + { + ratingKey: 'rk-1', + guid: 'guid-1', + title: 'Title', + author: 'Author', + narrator: 'Narr', + duration: 1000, + year: 2020, + summary: 'Summary', + thumb: '/thumb', + addedAt: 1, + updatedAt: 2, + userRating: 7, + }, + ]); + }); + + it('throws when fetching library content fails with 401', async () => { + clientMock.get.mockRejectedValue({ response: { status: 401 } }); + + const service = new PlexService(); + + await expect(service.getLibraryContent('http://plex', 'token', 'lib-1')).rejects.toThrow( + 'Failed to retrieve content from Plex library' + ); + }); + + it('returns recently added items from JSON responses', async () => { + clientMock.get.mockResolvedValue({ + data: { + MediaContainer: { + Metadata: [ + { + ratingKey: 'rk-2', + guid: 'guid-2', + title: 'New Title', + parentTitle: 'Author', + writer: 'Narrator', + duration: '2000', + year: '2021', + summary: 'Summary', + thumb: '/thumb2', + addedAt: '3', + updatedAt: '4', + userRating: '8', + }, + ], + }, + }, + }); + + const service = new PlexService(); + const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 5); + + expect(results).toEqual([ + { + ratingKey: 'rk-2', + guid: 'guid-2', + title: 'New Title', + author: 'Author', + narrator: 'Narrator', + duration: 2000, + year: 2021, + summary: 'Summary', + thumb: '/thumb2', + addedAt: 3, + updatedAt: 4, + userRating: 8, + }, + ]); + }); + + it('triggers Plex library scans', async () => { + clientMock.get.mockResolvedValue({ data: {} }); + + const service = new PlexService(); + await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).resolves.toBeUndefined(); + + expect(clientMock.get).toHaveBeenCalledWith( + 'http://plex/library/sections/lib-1/refresh', + expect.objectContaining({ + headers: { 'X-Plex-Token': 'token' }, + }) + ); + }); + + it('throws when scan triggers fail', async () => { + clientMock.get.mockRejectedValue(new Error('scan failed')); + + const service = new PlexService(); + + await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).rejects.toThrow( + 'Failed to trigger Plex library scan' + ); + }); + + it('collects ratings in batch and skips failures', async () => { + const service = new PlexService(); + const getItemSpy = vi.spyOn(service, 'getItemMetadata') + .mockResolvedValueOnce({ userRating: 4 }) + .mockRejectedValueOnce({ response: { status: 401 } }) + .mockResolvedValueOnce({ userRating: 3 }); + + const ratings = await service.batchGetUserRatings('http://plex', 'token', ['a', 'b', 'c']); + + expect(getItemSpy).toHaveBeenCalledTimes(3); + expect(ratings.get('a')).toBe(4); + expect(ratings.get('c')).toBe(3); + expect(ratings.has('b')).toBe(false); + }); + + it('extracts home users from API responses', async () => { + clientMock.get.mockResolvedValue({ + data: { + MediaContainer: { + User: [ + { + $: { + id: '1', + uuid: 'uuid', + title: 'User', + username: 'user', + email: 'user@example.com', + thumb: '/thumb', + hasPassword: '1', + restricted: '0', + admin: '1', + guest: '0', + protected: '0', + }, + }, + ], + }, + }, + }); + + const service = new PlexService(); + const users = await service.getHomeUsers('token'); + + expect(users).toHaveLength(1); + expect(users[0].friendlyName).toBe('User'); + expect(users[0].admin).toBe(true); + }); + + it('extracts home users from home.users responses', async () => { + clientMock.get.mockResolvedValue({ + data: { + home: { + users: [ + { + user: { + id: '2', + uuid: 'uuid-2', + title: 'Kid', + username: 'kid', + email: 'kid@example.com', + thumb: '/thumb', + hasPassword: 'true', + restricted: 'true', + admin: 'false', + guest: 'false', + protected: 'true', + }, + }, + ], + }, + }, + }); + + const service = new PlexService(); + const users = await service.getHomeUsers('token'); + + expect(users).toHaveLength(1); + expect(users[0].friendlyName).toBe('Kid'); + expect(users[0].restricted).toBe(true); + expect(users[0].protected).toBe(true); + }); + + it('returns empty list when no home users are available', async () => { + clientMock.get.mockResolvedValue({ data: {} }); + + const service = new PlexService(); + const users = await service.getHomeUsers('token'); + + expect(users).toEqual([]); + }); + + it('returns empty list when fetching home users fails', async () => { + clientMock.get.mockRejectedValue(new Error('home users down')); + + const service = new PlexService(); + const users = await service.getHomeUsers('token'); + + expect(users).toEqual([]); + }); + + it('switches home users and returns profile token', async () => { + clientMock.post.mockResolvedValue({ data: '' }); + parseStringPromiseMock.mockResolvedValue({ + user: { $: { authenticationToken: 'profile-token' } }, + }); + + const service = new PlexService(); + const token = await service.switchHomeUser('user-1', 'token'); + + expect(token).toBe('profile-token'); + }); + + it('returns null when switch response has no auth token', async () => { + clientMock.post.mockResolvedValue({ data: { user: { name: 'NoToken' } } }); + + const service = new PlexService(); + const token = await service.switchHomeUser('user-2', 'token'); + + expect(token).toBeNull(); + }); + + it('returns token from direct switch responses', async () => { + clientMock.post.mockResolvedValue({ data: { authenticationToken: 'token-1' } }); + + const service = new PlexService(); + const token = await service.switchHomeUser('user-4', 'token'); + + expect(token).toBe('token-1'); + }); + + it('returns token when authenticationToken is nested under user', async () => { + clientMock.post.mockResolvedValue({ data: { user: { authenticationToken: 'token-2' } } }); + + const service = new PlexService(); + const token = await service.switchHomeUser('user-5', 'token'); + + expect(token).toBe('token-2'); + }); + + it('returns token when authenticationToken is on root attributes', async () => { + clientMock.post.mockResolvedValue({ data: { $: { authenticationToken: 'token-3' } } }); + + const service = new PlexService(); + const token = await service.switchHomeUser('user-6', 'token'); + + expect(token).toBe('token-3'); + }); + + it('throws when switching home user with invalid PIN', async () => { + clientMock.post.mockRejectedValue({ response: { status: 401 } }); + + const service = new PlexService(); + + await expect(service.switchHomeUser('user-3', 'token', '1234')).rejects.toThrow('Invalid PIN'); + }); + + it('throws when switching home user fails for non-auth errors', async () => { + clientMock.post.mockRejectedValue({ response: { status: 500 }, message: 'boom' }); + + const service = new PlexService(); + + await expect(service.switchHomeUser('user-9', 'token')).rejects.toThrow( + 'Failed to switch to selected profile' + ); + }); + + it('returns a singleton instance from getPlexService', async () => { + const { getPlexService } = await import('@/lib/integrations/plex.service'); + const serviceA = getPlexService(); + const serviceB = getPlexService(); + + expect(serviceA).toBe(serviceB); + }); +}); diff --git a/tests/integrations/prowlarr.service.test.ts b/tests/integrations/prowlarr.service.test.ts new file mode 100644 index 0000000..01fa805 --- /dev/null +++ b/tests/integrations/prowlarr.service.test.ts @@ -0,0 +1,444 @@ +/** + * Component: Prowlarr Integration Service Tests + * Documentation: documentation/phase3/prowlarr.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProwlarrService } from '@/lib/integrations/prowlarr.service'; + +const clientMock = vi.hoisted(() => ({ + get: vi.fn(), + interceptors: { + request: { + use: vi.fn(), + }, + }, +})); + +const axiosMock = vi.hoisted(() => ({ + create: vi.fn(() => clientMock), + get: vi.fn(), +})); + +const configMock = vi.hoisted(() => ({ + get: vi.fn(), + getMany: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +describe('ProwlarrService', () => { + beforeEach(() => { + vi.clearAllMocks(); + clientMock.get.mockReset(); + axiosMock.get.mockReset(); + configMock.get.mockReset(); + }); + + it('filters results for SABnzbd (usenet)', async () => { + configMock.get.mockResolvedValue('sabnzbd'); + clientMock.get.mockResolvedValue({ + data: [ + { + guid: 'g1', + indexer: 'IndexerA', + title: 'Book NZB', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.nzb', + protocol: 'usenet', + }, + { + guid: 'g2', + indexer: 'IndexerB', + title: 'Book Torrent', + size: 200, + publishDate: '2024-01-02T00:00:00.000Z', + magnetUrl: 'magnet:?xt=urn:btih:abc', + protocol: 'torrent', + }, + ], + }); + + const service = new ProwlarrService('http://prowlarr', 'key'); + const results = await service.search('Book'); + + expect(results).toHaveLength(1); + expect(results[0].downloadUrl).toContain('.nzb'); + expect(results[0].protocol).toBe('usenet'); + }); + + it('throws when search fails', async () => { + configMock.get.mockResolvedValue('qbittorrent'); + clientMock.get.mockRejectedValue(new Error('bad search')); + + const service = new ProwlarrService('http://prowlarr', 'key'); + + await expect(service.search('Book')).rejects.toThrow('Failed to search Prowlarr: bad search'); + }); + + it('filters results for qBittorrent (torrent)', async () => { + configMock.get.mockResolvedValue('qbittorrent'); + clientMock.get.mockResolvedValue({ + data: [ + { + guid: 'g1', + indexer: 'IndexerA', + title: 'Book NZB', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.nzb', + protocol: 'usenet', + }, + { + guid: 'g2', + indexer: 'IndexerB', + title: 'Book Torrent', + size: 200, + publishDate: '2024-01-02T00:00:00.000Z', + magnetUrl: 'magnet:?xt=urn:btih:abc', + protocol: 'torrent', + }, + ], + }); + + const service = new ProwlarrService('http://prowlarr', 'key'); + const results = await service.search('Book'); + + expect(results).toHaveLength(1); + expect(results[0].downloadUrl).toContain('magnet:?'); + expect(results[0].protocol).toBe('torrent'); + }); + + it('parses RSS feeds into torrent results', async () => { + const xml = ` + + + + Great Book M4B 64kbps + https://example.com/book.torrent + guid-1 + Mon, 01 Jan 2024 00:00:00 GMT + IndexerA + + + + + + + `; + + axiosMock.get.mockResolvedValue({ data: xml }); + const service = new ProwlarrService('http://prowlarr', 'key'); + + const results = await service.getRssFeed(1); + + expect(results).toHaveLength(1); + expect(results[0].seeders).toBe(5); + expect(results[0].leechers).toBe(3); + expect(results[0].format).toBe('M4B'); + expect(results[0].bitrate).toBe('64kbps'); + expect(results[0].hasChapters).toBe(true); + }); + + it('skips RSS items missing download URLs', async () => { + const xml = ` + + + + Book Without Link + guid-2 + Mon, 01 Jan 2024 00:00:00 GMT + IndexerA + + + + `; + + axiosMock.get.mockResolvedValue({ data: xml }); + const service = new ProwlarrService('http://prowlarr', 'key'); + + const results = await service.getRssFeed(2); + + expect(results).toHaveLength(0); + }); + + it('detects NZB downloads by protocol or URL', () => { + expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/test.nzb' } as any)).toBe(true); + expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/getnzb?id=1' } as any)).toBe(true); + expect(ProwlarrService.isNZBResult({ downloadUrl: 'magnet:?xt=urn:btih:abc' } as any)).toBe(false); + expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/file', protocol: 'usenet' } as any)).toBe(true); + }); + + it('applies category, indexer, and seeder filters', async () => { + configMock.get.mockResolvedValue('qbittorrent'); + clientMock.get.mockResolvedValue({ + data: [ + { + guid: 'g1', + indexer: 'IndexerA', + title: 'Book One', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.torrent', + protocol: 'torrent', + seeders: 1, + }, + { + guid: 'g2', + indexer: 'IndexerB', + title: 'Book Two', + size: 200, + publishDate: '2024-01-02T00:00:00.000Z', + downloadUrl: 'https://example.com/book2.torrent', + protocol: 'torrent', + seeders: 10, + }, + ], + }); + + const service = new ProwlarrService('http://prowlarr', 'key'); + const results = await service.search('Book', { + categories: [3030, 3040], + minSeeders: 2, + maxResults: 1, + indexerIds: [1, 2], + }); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('Book Two'); + expect(clientMock.get).toHaveBeenCalledWith('/search', { + params: expect.objectContaining({ + categories: [3030, 3040], + indexerIds: [1, 2], + }), + }); + }); + + it('returns unfiltered results when protocol filtering fails', async () => { + configMock.get + .mockResolvedValueOnce('qbittorrent') + .mockRejectedValueOnce(new Error('config fail')); + + clientMock.get.mockResolvedValue({ + data: [ + { + guid: 'g1', + indexer: 'IndexerA', + title: 'Book NZB', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.nzb', + protocol: 'usenet', + }, + { + guid: 'g2', + indexer: 'IndexerB', + title: 'Book Torrent', + size: 200, + publishDate: '2024-01-02T00:00:00.000Z', + downloadUrl: 'https://example.com/book.torrent', + protocol: 'torrent', + }, + ], + }); + + const service = new ProwlarrService('http://prowlarr', 'key'); + const results = await service.search('Book'); + + expect(results).toHaveLength(2); + }); + + it('aggregates RSS feeds and ignores failures', async () => { + const service = new ProwlarrService('http://prowlarr', 'key'); + const rssSpy = vi.spyOn(service, 'getRssFeed') + .mockRejectedValueOnce(new Error('bad')) + .mockResolvedValueOnce([{ guid: 'g1' } as any]); + + const results = await service.getAllRssFeeds([1, 2]); + + expect(rssSpy).toHaveBeenCalledTimes(2); + expect(results).toHaveLength(1); + }); + + it('skips results without download URLs', () => { + const service = new ProwlarrService('http://prowlarr', 'key'); + const result = (service as any).transformResult({ + guid: 'g1', + indexer: 'IndexerA', + title: 'No URL', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + }); + + expect(result).toBeNull(); + }); + + it('extracts flags from indexer fields and title metadata', () => { + const service = new ProwlarrService('http://prowlarr', 'key'); + const result = (service as any).transformResult({ + guid: 'g3', + indexer: 'IndexerA', + title: 'Book M4A 128kbps', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.torrent', + indexerFlags: ['Trusted', 2], + flags: ['Featured', 'Trusted'], + }); + + expect(result?.flags).toEqual(['Trusted', 'Featured']); + expect(result?.format).toBe('M4A'); + expect(result?.bitrate).toBe('128kbps'); + }); + + it('derives flags from volume factors when no explicit flags exist', () => { + const service = new ProwlarrService('http://prowlarr', 'key'); + const result = (service as any).transformResult({ + guid: 'g4', + indexer: 'IndexerB', + title: 'Book MP3', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.torrent', + downloadVolumeFactor: 0, + uploadVolumeFactor: 2, + }); + + expect(result?.flags).toEqual(['Freeleech', 'Double Upload']); + expect(result?.format).toBe('MP3'); + }); + + it('marks partial freeleech when download volume factor is reduced', () => { + const service = new ProwlarrService('http://prowlarr', 'key'); + const result = (service as any).transformResult({ + guid: 'g5', + indexer: 'IndexerC', + title: 'Book MP3', + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.torrent', + downloadVolumeFactor: 0.5, + }); + + expect(result?.flags).toEqual(['Partial Freeleech']); + }); + + it('returns null when transformResult throws', () => { + const service = new ProwlarrService('http://prowlarr', 'key'); + const result = (service as any).transformResult({ + guid: 'g6', + indexer: 'IndexerD', + title: null, + size: 100, + publishDate: '2024-01-01T00:00:00.000Z', + downloadUrl: 'https://example.com/book.torrent', + }); + + expect(result).toBeNull(); + }); + + it('returns indexers and stats', async () => { + clientMock.get + .mockResolvedValueOnce({ data: [{ id: 1, name: 'IndexerA' }] }) + .mockResolvedValueOnce({ data: { indexers: [] } }); + + const service = new ProwlarrService('http://prowlarr', 'key'); + const indexers = await service.getIndexers(); + const stats = await service.getStats(); + + expect(indexers).toHaveLength(1); + expect(stats.indexers).toEqual([]); + }); + + it('returns false when connection test fails', async () => { + clientMock.get.mockRejectedValue(new Error('health down')); + + const service = new ProwlarrService('http://prowlarr', 'key'); + const ok = await service.testConnection(); + + expect(ok).toBe(false); + }); + + it('throws when indexer stats cannot be fetched', async () => { + clientMock.get.mockRejectedValue(new Error('no stats')); + + const service = new ProwlarrService('http://prowlarr', 'key'); + + await expect(service.getStats()).rejects.toThrow('Failed to get indexer statistics'); + }); + + it('returns a singleton service from configuration', async () => { + const originalApiKey = process.env.PROWLARR_API_KEY; + delete process.env.PROWLARR_API_KEY; + vi.resetModules(); + + configMock.getMany.mockResolvedValue({ + prowlarr_url: 'http://prowlarr', + prowlarr_api_key: 'api-key', + }); + clientMock.get.mockResolvedValue({ data: {} }); + + const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service'); + const serviceA = await getProwlarrService(); + const serviceB = await getProwlarrService(); + + expect(serviceA).toBe(serviceB); + + if (originalApiKey === undefined) { + delete process.env.PROWLARR_API_KEY; + } else { + process.env.PROWLARR_API_KEY = originalApiKey; + } + }); + + it('throws when Prowlarr API key is missing', async () => { + const originalApiKey = process.env.PROWLARR_API_KEY; + delete process.env.PROWLARR_API_KEY; + vi.resetModules(); + + configMock.getMany.mockResolvedValue({ + prowlarr_url: 'http://prowlarr', + prowlarr_api_key: '', + }); + + const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service'); + await expect(getProwlarrService()).rejects.toThrow('Prowlarr API key not configured'); + + if (originalApiKey === undefined) { + delete process.env.PROWLARR_API_KEY; + } else { + process.env.PROWLARR_API_KEY = originalApiKey; + } + }); + + it('returns service even when connection test fails', async () => { + const originalApiKey = process.env.PROWLARR_API_KEY; + delete process.env.PROWLARR_API_KEY; + vi.resetModules(); + + configMock.getMany.mockResolvedValue({ + prowlarr_url: 'http://prowlarr', + prowlarr_api_key: 'api-key', + }); + clientMock.get.mockRejectedValue(new Error('health down')); + + const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service'); + const service = await getProwlarrService(); + + expect(service).toBeDefined(); + + if (originalApiKey === undefined) { + delete process.env.PROWLARR_API_KEY; + } else { + process.env.PROWLARR_API_KEY = originalApiKey; + } + }); +}); diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts new file mode 100644 index 0000000..0208046 --- /dev/null +++ b/tests/integrations/qbittorrent.service.test.ts @@ -0,0 +1,655 @@ +/** + * Component: qBittorrent Integration Service Tests + * Documentation: documentation/phase3/qbittorrent.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service'; + +const clientMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +const axiosMock = vi.hoisted(() => ({ + create: vi.fn(() => clientMock), + post: vi.fn(), + get: vi.fn(), + isAxiosError: (error: any) => Boolean(error?.isAxiosError), +})); + +const parseTorrentMock = vi.hoisted(() => vi.fn()); +const configServiceMock = vi.hoisted(() => ({ + getMany: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('parse-torrent', () => ({ + default: parseTorrentMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('QBittorrentService', () => { + beforeEach(() => { + vi.clearAllMocks(); + clientMock.get.mockReset(); + clientMock.post.mockReset(); + axiosMock.get.mockReset(); + axiosMock.post.mockReset(); + parseTorrentMock.mockReset(); + configServiceMock.getMany.mockReset(); + invalidateQBittorrentService(); + }); + + it('maps download progress from torrent info', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0.42, + downloaded: 420, + size: 1000, + dlspeed: 50, + eta: 120, + state: 'pausedDL', + } as any); + + expect(progress.percent).toBe(42); + expect(progress.bytesDownloaded).toBe(420); + expect(progress.bytesTotal).toBe(1000); + expect(progress.speed).toBe(50); + expect(progress.eta).toBe(120); + expect(progress.state).toBe('paused'); + }); + + it('extracts info hash from magnet links', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const hash = (service as any).extractHashFromMagnet( + 'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567' + ); + + expect(hash).toBe('0123456789abcdef0123456789abcdef01234567'); + expect((service as any).extractHashFromMagnet('magnet:?xt=urn:btih:')).toBeNull(); + }); + + it('maps allocating state to downloading', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0.1, + downloaded: 100, + size: 1000, + dlspeed: 0, + eta: 0, + state: 'allocating' as any, + } as any); + + expect(progress.state).toBe('downloading'); + }); + + it('authenticates and stores a session cookie', async () => { + axiosMock.post.mockResolvedValue({ + status: 200, + statusText: 'OK', + data: 'Ok.', + headers: { 'set-cookie': ['SID=abc; Path=/;'] }, + }); + + const service = new QBittorrentService('http://qb', 'user', 'pass'); + await service.login(); + + expect((service as any).cookie).toBe('SID=abc'); + }); + + it('throws when login response lacks a cookie', async () => { + axiosMock.post.mockResolvedValue({ + status: 200, + statusText: 'OK', + data: 'Ok.', + headers: {}, + }); + + const service = new QBittorrentService('http://qb', 'user', 'pass'); + + await expect(service.login()).rejects.toThrow('Failed to authenticate with qBittorrent'); + }); + + it('rejects empty torrent URLs', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + + await expect(service.addTorrent('')).rejects.toThrow('Invalid download URL'); + }); + + it('skips adding duplicate magnet links', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=dup'; + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + vi.spyOn(service as any, 'getTorrent').mockResolvedValue({ hash: 'existing' }); + + const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'); + + expect(hash).toBe('0123456789abcdef0123456789abcdef01234567'); + expect(clientMock.post).not.toHaveBeenCalled(); + }); + + it('adds magnet links when not already present', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=add'; + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found')); + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + const hash = await service.addTorrent( + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567', + { tags: ['tag1', 'tag2'] } + ); + + expect(hash).toBe('0123456789abcdef0123456789abcdef01234567'); + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/add', + expect.any(URLSearchParams), + expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }), + }) + ); + }); + + it('throws when magnet link is invalid', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=badmagnet'; + + await expect( + (service as any).addMagnetLink('magnet:?xt=urn:btih:', 'readmeabook') + ).rejects.toThrow('Invalid magnet link'); + }); + + it('throws when qBittorrent rejects magnet uploads', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=rejected'; + vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found')); + clientMock.post.mockResolvedValue({ data: 'Nope' }); + + await expect( + (service as any).addMagnetLink( + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567', + 'readmeabook' + ) + ).rejects.toThrow('qBittorrent rejected magnet link'); + }); + + it('re-authenticates after a 403 and retries adding torrents', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=old'; + + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + const loginSpy = vi.spyOn(service, 'login').mockResolvedValue(); + const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink') + .mockRejectedValueOnce({ isAxiosError: true, response: { status: 403 } }) + .mockResolvedValueOnce('rehash'); + + const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'); + + expect(hash).toBe('rehash'); + expect(loginSpy).toHaveBeenCalledTimes(1); + expect(addMagnetSpy).toHaveBeenCalledTimes(2); + }); + + it('follows redirect to magnet link when downloading torrent files', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=redir'; + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('redirect-hash'); + + axiosMock.get.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 302, headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' } }, + }); + + const hash = await service.addTorrent('http://example.com/file.torrent'); + + expect(hash).toBe('redirect-hash'); + expect(addMagnetSpy).toHaveBeenCalled(); + }); + + it('treats magnet response bodies as magnet links', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=body'; + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('body-hash'); + + axiosMock.get.mockResolvedValueOnce({ + data: Buffer.from('magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01'), + }); + + const hash = await service.addTorrent('http://example.com/file.torrent'); + + expect(hash).toBe('body-hash'); + expect(addMagnetSpy).toHaveBeenCalled(); + expect(parseTorrentMock).not.toHaveBeenCalled(); + }); + + it('adds torrent files after parsing successfully', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=ok'; + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found')); + + axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') }); + parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-1', name: 'Book' }); + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + const hash = await service.addTorrent('http://example.com/file.torrent'); + + expect(hash).toBe('hash-1'); + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/add', + expect.any(Object), + expect.objectContaining({ maxBodyLength: Infinity }) + ); + }); + + it('throws for invalid redirect locations when fetching torrents', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + + axiosMock.get.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 302, headers: { location: 'ftp://bad' } }, + message: 'redirect', + }); + + await expect( + (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook') + ).rejects.toThrow('Invalid redirect location'); + }); + + it('throws when torrent file parsing fails directly', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + + axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') }); + parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent')); + + await expect( + (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook') + ).rejects.toThrow('Invalid .torrent file - failed to parse'); + }); + + it('throws when torrent file has no info hash', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + + axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') }); + parseTorrentMock.mockResolvedValueOnce({ infoHash: null }); + + await expect( + (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook') + ).rejects.toThrow('Failed to extract info_hash'); + }); + + it('throws when qBittorrent rejects torrent file uploads', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=reject'; + vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found')); + + axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') }); + parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-2', name: 'Book' }); + clientMock.post.mockResolvedValue({ data: 'Nope' }); + + await expect( + (service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook') + ).rejects.toThrow('qBittorrent rejected .torrent file'); + }); + + it('throws when torrent parsing fails', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=parse'; + vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined); + + axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') }); + parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent')); + + await expect(service.addTorrent('http://example.com/file.torrent')).rejects.toThrow( + 'Failed to add torrent to qBittorrent' + ); + }); + + it('creates categories when missing', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads'); + (service as any).cookie = 'SID=newcat'; + clientMock.get.mockResolvedValue({ data: {} }); + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + await (service as any).ensureCategory('readmeabook'); + + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/createCategory', + expect.any(URLSearchParams), + expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }), + }) + ); + }); + + it('does not throw when ensuring categories fails', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=catfail'; + clientMock.get.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 500 }, + }); + + await expect((service as any).ensureCategory('readmeabook')).resolves.toBeUndefined(); + }); + + it('updates category when save path mismatches', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads'); + (service as any).cookie = 'SID=cat'; + clientMock.get.mockResolvedValue({ + data: { + readmeabook: { savePath: '/old' }, + }, + }); + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + await (service as any).ensureCategory('readmeabook'); + + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/editCategory', + expect.any(URLSearchParams), + expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }), + }) + ); + }); + + it('does not update category when save path matches', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads'); + (service as any).cookie = 'SID=cat-ok'; + clientMock.get.mockResolvedValue({ + data: { + readmeabook: { savePath: '/downloads' }, + }, + }); + + await (service as any).ensureCategory('readmeabook'); + + expect(clientMock.post).not.toHaveBeenCalled(); + }); + + it('pauses and resumes torrents', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=pause'; + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + await service.pauseTorrent('hash-1'); + await service.resumeTorrent('hash-1'); + + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/pause', + expect.any(URLSearchParams), + expect.any(Object) + ); + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/resume', + expect.any(URLSearchParams), + expect.any(Object) + ); + }); + + it('throws when torrent state updates fail', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=fail'; + clientMock.post.mockRejectedValue(new Error('boom')); + + await expect(service.pauseTorrent('hash-1')).rejects.toThrow('Failed to pause torrent'); + await expect(service.resumeTorrent('hash-1')).rejects.toThrow('Failed to resume torrent'); + await expect(service.deleteTorrent('hash-1', false)).rejects.toThrow('Failed to delete torrent'); + await expect(service.setCategory('hash-1', 'books')).rejects.toThrow('Failed to set torrent category'); + }); + + it('sets categories, deletes torrents, and fetches files', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=ops'; + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + clientMock.get.mockResolvedValue({ data: [{ name: 'file1' }] }); + + await service.setCategory('hash-1', 'books'); + await service.deleteTorrent('hash-1', true); + const files = await service.getFiles('hash-1'); + + expect(files).toEqual([{ name: 'file1' }]); + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/setCategory', + expect.any(URLSearchParams), + expect.any(Object) + ); + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/delete', + expect.any(URLSearchParams), + expect.any(Object) + ); + }); + + it('throws when fetching torrent files fails', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=files'; + clientMock.get.mockRejectedValue(new Error('no files')); + + await expect(service.getFiles('hash-1')).rejects.toThrow('Failed to get torrent files'); + }); + + it('throws when torrent is not found', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=missing'; + clientMock.get.mockResolvedValueOnce({ data: [] }); + + await expect(service.getTorrent('hash-404')).rejects.toThrow('Torrent hash-404 not found'); + }); + + it('returns error when getTorrents fails', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=list'; + clientMock.get.mockRejectedValue(new Error('boom')); + + await expect(service.getTorrents()).rejects.toThrow('Failed to get torrents from qBittorrent'); + }); + + it('returns torrent lists with a category filter', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=list'; + clientMock.get.mockResolvedValueOnce({ data: [{ hash: 'h1' }] }); + + const torrents = await service.getTorrents('books'); + + expect(torrents).toEqual([{ hash: 'h1' }]); + expect(clientMock.get).toHaveBeenCalledWith( + '/torrents/info', + expect.objectContaining({ params: { category: 'books' } }) + ); + }); + + it('returns unknown state for unrecognized torrent states', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0, + downloaded: 0, + size: 1, + dlspeed: 0, + eta: 0, + state: 'weird' as any, + } as any); + + expect(progress.state).toBe('unknown'); + }); + + it('throws specific errors for invalid credentials in testConnectionWithCredentials', async () => { + axiosMock.post.mockResolvedValueOnce({ + status: 200, + statusText: 'OK', + data: 'Ok.', + headers: { 'set-cookie': ['SID=abc; Path=/;'] }, + }); + axiosMock.get.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 401 }, + config: { url: 'http://qb/api/v2/app/version' }, + message: 'Unauthorized', + }); + + await expect( + QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'bad') + ).rejects.toThrow('Authentication failed'); + }); + + it('returns version on successful credential test', async () => { + axiosMock.post.mockResolvedValueOnce({ + status: 200, + statusText: 'OK', + data: 'Ok.', + headers: { 'set-cookie': ['SID=abc; Path=/;'] }, + }); + axiosMock.get.mockResolvedValueOnce({ + data: 'v4.6.0', + headers: {}, + }); + + const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass'); + + expect(version).toBe('v4.6.0'); + }); + + it('throws when test connection receives no cookies', async () => { + axiosMock.post.mockResolvedValueOnce({ + status: 200, + statusText: 'OK', + data: 'Ok.', + headers: {}, + }); + + await expect( + QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass') + ).rejects.toThrow('Failed to authenticate - no session cookie received'); + }); + + it('throws SSL-specific errors for certificate failures', async () => { + axiosMock.post.mockRejectedValueOnce({ + isAxiosError: true, + code: 'DEPTH_ZERO_SELF_SIGNED_CERT', + message: 'self signed', + config: { url: 'https://qb/api/v2/auth/login' }, + }); + + await expect( + QBittorrentService.testConnectionWithCredentials('https://qb', 'user', 'pass', true) + ).rejects.toThrow('SSL certificate verification failed'); + }); + + it('throws when connection is refused', async () => { + axiosMock.post.mockRejectedValueOnce({ + isAxiosError: true, + code: 'ECONNREFUSED', + message: 'refused', + config: { url: 'http://qb/api/v2/auth/login' }, + }); + + await expect( + QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass') + ).rejects.toThrow('Connection refused'); + }); + + it('throws when server returns 404', async () => { + axiosMock.post.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 404 }, + message: 'Not found', + config: { url: 'http://qb/api/v2/auth/login' }, + }); + + await expect( + QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass') + ).rejects.toThrow('qBittorrent Web UI not found'); + }); + + it('throws on qBittorrent server errors', async () => { + axiosMock.post.mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 503 }, + message: 'Server error', + config: { url: 'http://qb/api/v2/auth/login' }, + }); + + await expect( + QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass') + ).rejects.toThrow('qBittorrent server error'); + }); + + it('throws when qBittorrent configuration is incomplete', async () => { + configServiceMock.getMany.mockResolvedValue({ + download_client_url: null, + download_client_username: null, + download_client_password: null, + download_dir: null, + download_client_disable_ssl_verify: 'false', + }); + + await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not fully configured'); + }); + + it('returns a cached instance after successful initialization', async () => { + configServiceMock.getMany.mockResolvedValue({ + download_client_url: 'http://qb', + download_client_username: 'user', + download_client_password: 'pass', + download_dir: '/downloads', + download_client_disable_ssl_verify: 'false', + }); + + const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true); + + const first = await getQBittorrentService(); + const second = await getQBittorrentService(); + + expect(first).toBe(second); + expect(configServiceMock.getMany).toHaveBeenCalledTimes(1); + + testConnectionSpy.mockRestore(); + }); + + it('throws when connection test fails during service creation', async () => { + configServiceMock.getMany.mockResolvedValue({ + download_client_url: 'http://qb', + download_client_username: 'user', + download_client_password: 'pass', + download_dir: '/downloads', + download_client_disable_ssl_verify: 'false', + }); + + const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false); + + await expect(getQBittorrentService()).rejects.toThrow('qBittorrent connection test failed'); + + testConnectionSpy.mockRestore(); + }); + + it('returns false when connection test fails', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth')); + + const ok = await service.testConnection(); + + expect(ok).toBe(false); + expect(loginSpy).toHaveBeenCalled(); + }); + + it('returns true when connection test succeeds', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const loginSpy = vi.spyOn(service, 'login').mockResolvedValue(); + + const ok = await service.testConnection(); + + expect(ok).toBe(true); + expect(loginSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/integrations/sabnzbd.service.test.ts b/tests/integrations/sabnzbd.service.test.ts new file mode 100644 index 0000000..59e7094 --- /dev/null +++ b/tests/integrations/sabnzbd.service.test.ts @@ -0,0 +1,486 @@ +/** + * Component: SABnzbd Integration Service Tests + * Documentation: documentation/phase3/sabnzbd.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SABnzbdService, getSABnzbdService, invalidateSABnzbdService } from '@/lib/integrations/sabnzbd.service'; + +const clientMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const axiosMock = vi.hoisted(() => ({ + create: vi.fn(() => clientMock), +})); + +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('SABnzbdService', () => { + beforeEach(() => { + vi.clearAllMocks(); + clientMock.get.mockReset(); + configServiceMock.get.mockReset(); + invalidateSABnzbdService(); + }); + + it('fails connection when API key is missing', async () => { + const service = new SABnzbdService('http://sab', ''); + + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toContain('API key is required'); + expect(clientMock.get).not.toHaveBeenCalled(); + }); + + it('returns a friendly error for invalid API key', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { status: false, error: 'API Key Incorrect' }, + }); + + const service = new SABnzbdService('http://sab', 'bad-key'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid API key'); + expect(clientMock.get).toHaveBeenCalledTimes(1); + }); + + it('returns non-API key errors from the server', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { status: false, error: 'No permissions' }, + }); + + const service = new SABnzbdService('http://sab', 'bad-key'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toBe('No permissions'); + }); + + it('returns version when connection succeeds', async () => { + clientMock.get + .mockResolvedValueOnce({ data: { status: true } }) + .mockResolvedValueOnce({ data: { version: '4.0.0' } }); + + const service = new SABnzbdService('http://sab', 'good-key'); + const result = await service.testConnection(); + + expect(result.success).toBe(true); + expect(result.version).toBe('4.0.0'); + expect(clientMock.get).toHaveBeenCalledTimes(2); + }); + + it('returns SSL error message when certificate issues occur', async () => { + clientMock.get.mockRejectedValueOnce(new Error('certificate error')); + + const service = new SABnzbdService('https://sab', 'key'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toContain('SSL/TLS certificate error'); + }); + + it('returns a friendly error on connection refused', async () => { + clientMock.get.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const service = new SABnzbdService('http://sab', 'key'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Connection refused'); + }); + + it('adds NZB with mapped priority', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { status: true, nzo_ids: ['nzb-1'] }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + const nzbId = await service.addNZB('https://example.com/book.nzb', { + category: 'books', + priority: 'high', + }); + + const params = clientMock.get.mock.calls[0][1].params; + expect(nzbId).toBe('nzb-1'); + expect(params.cat).toBe('books'); + expect(params.priority).toBe('1'); + }); + + it('adds NZB with force priority', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { status: true, nzo_ids: ['nzb-9'] }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + await service.addNZB('https://example.com/book.nzb', { priority: 'force' }); + + const params = clientMock.get.mock.calls[0][1].params; + expect(params.priority).toBe('2'); + }); + + it('returns queue item info when NZB is active', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { + queue: { + slots: [ + { + nzo_id: 'nzb-2', + filename: 'Queue Book', + mb: '10', + mbleft: '5', + percentage: '50', + status: 'Paused', + timeleft: '0:00:10', + cat: 'readmeabook', + priority: 'Normal', + }, + ], + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + const info = await service.getNZB('nzb-2'); + + expect(info?.nzbId).toBe('nzb-2'); + expect(info?.progress).toBe(0.5); + expect(info?.status).toBe('paused'); + expect(info?.size).toBe(10 * 1024 * 1024); + expect(info?.timeLeft).toBe(10); + }); + + it('maps queue slots from getQueue', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { + queue: { + slots: [ + { + nzo_id: 'nzb-10', + filename: 'Queue Book', + mb: '5', + mbleft: '2', + percentage: '40', + status: 'Queued', + timeleft: '0:01:00', + cat: 'readmeabook', + priority: 'Normal', + }, + ], + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + const queue = await service.getQueue(); + + expect(queue[0]).toEqual(expect.objectContaining({ + nzbId: 'nzb-10', + name: 'Queue Book', + size: 5, + sizeLeft: 2, + percentage: 40, + status: 'Queued', + })); + }); + + it('maps history slots from getHistory', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { + history: { + slots: [ + { + nzo_id: 'nzb-11', + name: 'History Book', + category: 'readmeabook', + status: 'Failed', + bytes: '1024', + fail_message: 'Failed', + storage: '/downloads', + completed: '1700000001', + download_time: '60', + }, + ], + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + const history = await service.getHistory(1); + + expect(history[0]).toEqual(expect.objectContaining({ + nzbId: 'nzb-11', + status: 'Failed', + bytes: '1024', + failMessage: 'Failed', + })); + }); + + it('returns history item info when NZB has completed', async () => { + clientMock.get + .mockResolvedValueOnce({ data: { queue: { slots: [] } } }) + .mockResolvedValueOnce({ + data: { + history: { + slots: [ + { + nzo_id: 'nzb-3', + name: 'History Book', + category: 'readmeabook', + status: 'Completed', + bytes: '2048', + fail_message: '', + storage: '/downloads/book', + completed: '1700000000', + download_time: '60', + }, + ], + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + const info = await service.getNZB('nzb-3'); + + expect(info?.nzbId).toBe('nzb-3'); + expect(info?.progress).toBe(1); + expect(info?.status).toBe('completed'); + expect(info?.downloadPath).toBe('/downloads/book'); + expect(info?.completedAt?.getTime()).toBe(1700000000 * 1000); + }); + + it('returns history item info when NZB has failed', async () => { + clientMock.get + .mockResolvedValueOnce({ data: { queue: { slots: [] } } }) + .mockResolvedValueOnce({ + data: { + history: { + slots: [ + { + nzo_id: 'nzb-12', + name: 'Failed Book', + category: 'readmeabook', + status: 'Failed', + bytes: '2048', + fail_message: 'Bad nzb', + storage: '/downloads/book', + completed: '1700000002', + download_time: '30', + }, + ], + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + const info = await service.getNZB('nzb-12'); + + expect(info?.status).toBe('failed'); + expect(info?.errorMessage).toBe('Bad nzb'); + }); + + it('maps repairing status in download progress', () => { + const service = new SABnzbdService('http://sab', 'key'); + const progress = service.getDownloadProgress({ + nzbId: 'nzb-4', + name: 'Repairing Book', + size: 1, + sizeLeft: 1, + percentage: 100, + status: 'Repairing', + timeLeft: '0:00:00', + category: 'readmeabook', + priority: 'Normal', + }); + + expect(progress.state).toBe('repairing'); + expect(progress.percent).toBe(1); + }); + + it('maps queued and extracting status in download progress', () => { + const service = new SABnzbdService('http://sab', 'key'); + const queued = service.getDownloadProgress({ + nzbId: 'nzb-5', + name: 'Queued Book', + size: 2, + sizeLeft: 2, + percentage: 0, + status: 'Queued', + timeLeft: '0:10:00', + category: 'readmeabook', + priority: 'Normal', + }); + + const extracting = service.getDownloadProgress({ + nzbId: 'nzb-6', + name: 'Extracting Book', + size: 2, + sizeLeft: 1, + percentage: 50, + status: 'Extracting', + timeLeft: '0:05:00', + category: 'readmeabook', + priority: 'Normal', + }); + + expect(queued.state).toBe('queued'); + expect(extracting.state).toBe('extracting'); + }); + + it('maps completed status when percentage is 100', () => { + const service = new SABnzbdService('http://sab', 'key'); + const progress = service.getDownloadProgress({ + nzbId: 'nzb-7', + name: 'Done Book', + size: 1, + sizeLeft: 0, + percentage: 100, + status: 'Downloading', + timeLeft: '0:00:00', + category: 'readmeabook', + priority: 'Normal', + }); + + expect(progress.state).toBe('completed'); + expect(progress.percent).toBe(1); + }); + + it('creates the default category when missing', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { config: { version: '1', categories: {} } }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + const service = new SABnzbdService('http://sab', 'key', 'readmeabook'); + await service.ensureCategory('/downloads'); + + expect(clientMock.get).toHaveBeenCalledWith('/api', expect.objectContaining({ + params: expect.objectContaining({ mode: 'set_config', keyword: 'readmeabook' }), + })); + }); + + it('swallows errors when ensuring categories fails', async () => { + const service = new SABnzbdService('http://sab', 'key', 'readmeabook'); + const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config')); + + await expect(service.ensureCategory('/downloads')).resolves.toBeUndefined(); + + configSpy.mockRestore(); + }); + + it('does not create category when it already exists', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { + config: { + version: '1', + categories: { readmeabook: { dir: '/downloads' } }, + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key', 'readmeabook'); + await service.ensureCategory('/downloads'); + + expect(clientMock.get).toHaveBeenCalledTimes(1); + expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config'); + }); + it('throws when addNZB reports a failure', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { status: false, error: 'Bad NZB' }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + + await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB'); + }); + + it('throws when SABnzbd returns no NZB IDs', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { status: true, nzo_ids: [] }, + }); + + const service = new SABnzbdService('http://sab', 'key'); + + await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('did not return an NZB ID'); + }); + + it('returns null when NZB is not found in queue or history', async () => { + clientMock.get + .mockResolvedValueOnce({ data: { queue: { slots: [] } } }) + .mockResolvedValueOnce({ data: { history: { slots: [] } } }); + + const service = new SABnzbdService('http://sab', 'key'); + const info = await service.getNZB('missing'); + + expect(info).toBeNull(); + }); + + it('returns an error message for connection timeouts', async () => { + clientMock.get.mockRejectedValueOnce(new Error('ETIMEDOUT')); + + const service = new SABnzbdService('http://sab', 'key'); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toContain('timed out'); + }); + + it('throws when version is missing from response', async () => { + clientMock.get.mockResolvedValueOnce({ data: {} }); + + const service = new SABnzbdService('http://sab', 'key'); + + await expect(service.getVersion()).rejects.toThrow('Failed to get SABnzbd version'); + }); + + it('throws when config payload is missing', async () => { + clientMock.get.mockResolvedValueOnce({ data: {} }); + + const service = new SABnzbdService('http://sab', 'key'); + + await expect(service.getConfig()).rejects.toThrow('Failed to get SABnzbd configuration'); + }); + + it('creates a singleton service from config', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + switch (key) { + case 'download_client_url': + return 'http://sab'; + case 'download_client_password': + return 'api-key'; + case 'sabnzbd_category': + return 'books'; + case 'download_client_disable_ssl_verify': + return 'false'; + case 'download_dir': + return '/downloads'; + default: + return null; + } + }); + + const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue(); + + const service = await getSABnzbdService(); + const again = await getSABnzbdService(); + + expect(service).toBe(again); + expect(ensureSpy).toHaveBeenCalledWith('/downloads'); + + ensureSpy.mockRestore(); + }); +}); diff --git a/tests/middleware/auth.middleware.test.ts b/tests/middleware/auth.middleware.test.ts new file mode 100644 index 0000000..e6f9436 --- /dev/null +++ b/tests/middleware/auth.middleware.test.ts @@ -0,0 +1,177 @@ +/** + * Component: Auth Middleware Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextResponse } from 'next/server'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const verifyAccessTokenMock = vi.fn(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + verifyAccessToken: verifyAccessTokenMock, +})); + +const makeRequest = (authHeader?: string) => ({ + headers: { + get: (key: string) => { + if (key.toLowerCase() === 'authorization') { + return authHeader ?? null; + } + return null; + }, + }, +}); + +describe('auth middleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects requests without a token', async () => { + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest() as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('rejects invalid tokens', async () => { + verifyAccessTokenMock.mockReturnValue(null); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest('Bearer badtoken') as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/invalid/i); + }); + + it('rejects tokens for missing users', async () => { + verifyAccessTokenMock.mockReturnValue({ + sub: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'user', + iat: 1, + exp: 2, + }); + prismaMock.user.findUnique.mockResolvedValue(null); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn()); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.message).toMatch(/user not found/i); + }); + + it('passes authenticated requests to handler', async () => { + verifyAccessTokenMock.mockReturnValue({ + sub: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'user', + iat: 1, + exp: 2, + }); + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' }); + const { requireAuth } = await import('@/lib/middleware/auth'); + + const handler = vi.fn(async (req: any) => + NextResponse.json({ ok: true, userId: req.user?.id }) + ); + const response = await requireAuth(makeRequest('Bearer token') as any, handler); + const payload = await response.json(); + + expect(handler).toHaveBeenCalled(); + expect(payload.userId).toBe('user-1'); + }); + + it('requires admin role', async () => { + const { requireAdmin } = await import('@/lib/middleware/auth'); + + const noUserResponse = await requireAdmin({} as any, vi.fn()); + expect(noUserResponse.status).toBe(401); + + const response = await requireAdmin({ user: { role: 'user' } } as any, vi.fn()); + expect(response.status).toBe(403); + }); + + it('allows admin users', async () => { + const { requireAdmin } = await import('@/lib/middleware/auth'); + + const handler = vi.fn(async () => NextResponse.json({ ok: true })); + const response = await requireAdmin({ user: { role: 'admin' } } as any, handler); + + expect(handler).toHaveBeenCalled(); + expect(response.status).toBe(200); + }); + + it('requires local admin with setup flag', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + isSetupAdmin: true, + plexId: 'local-admin', + }); + const { requireLocalAdmin } = await import('@/lib/middleware/auth'); + + const handler = vi.fn(async () => NextResponse.json({ ok: true })); + const response = await requireLocalAdmin( + { user: { id: 'user-1', role: 'admin' } } as any, + handler + ); + + expect(handler).toHaveBeenCalled(); + expect(response.status).toBe(200); + }); + + it('rejects non-local admins', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + isSetupAdmin: false, + plexId: 'plex-user', + }); + const { requireLocalAdmin } = await import('@/lib/middleware/auth'); + + const response = await requireLocalAdmin( + { user: { id: 'user-1', role: 'admin' } } as any, + vi.fn() + ); + + expect(response.status).toBe(403); + }); + + it('checks local admin helper', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + isSetupAdmin: true, + plexId: 'local-admin', + }); + const { isLocalAdmin } = await import('@/lib/middleware/auth'); + + const result = await isLocalAdmin('user-1'); + expect(result).toBe(true); + }); + + it('returns current user from token', async () => { + verifyAccessTokenMock.mockReturnValue({ + sub: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'admin', + iat: 1, + exp: 2, + }); + const { getCurrentUser, isAdmin } = await import('@/lib/middleware/auth'); + + const payload = getCurrentUser(makeRequest('Bearer token') as any); + expect(payload?.sub).toBe('user-1'); + expect(isAdmin(payload)).toBe(true); + }); +}); diff --git a/tests/processors/audible-refresh.processor.test.ts b/tests/processors/audible-refresh.processor.test.ts new file mode 100644 index 0000000..6d88b1e --- /dev/null +++ b/tests/processors/audible-refresh.processor.test.ts @@ -0,0 +1,114 @@ +/** + * Component: Audible Refresh Processor Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const audibleServiceMock = vi.hoisted(() => ({ + getPopularAudiobooks: vi.fn(), + getNewReleases: vi.fn(), +})); +const thumbnailCacheMock = vi.hoisted(() => ({ + cacheThumbnail: vi.fn(), + cleanupUnusedThumbnails: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => audibleServiceMock, +})); + +vi.mock('@/lib/services/thumbnail-cache.service', () => ({ + getThumbnailCacheService: () => thumbnailCacheMock, +})); + +describe('processAudibleRefresh', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('refreshes popular and new releases, caching thumbnails', async () => { + const popular = [ + { + asin: 'ASIN-1', + title: 'Popular One', + author: 'Author A', + narrator: 'Narrator A', + description: 'Desc', + coverArtUrl: 'http://image/1', + durationMinutes: 120, + releaseDate: '2024-01-01', + rating: 4.8, + genres: ['fiction'], + }, + { + asin: 'ASIN-2', + title: 'Popular Two', + author: 'Author B', + narrator: 'Narrator B', + description: 'Desc', + coverArtUrl: null, + durationMinutes: 90, + releaseDate: null, + rating: null, + genres: [], + }, + ]; + const newReleases = [ + { + asin: 'ASIN-3', + title: 'New Release', + author: 'Author C', + narrator: 'Narrator C', + description: 'Desc', + coverArtUrl: 'http://image/3', + durationMinutes: 200, + releaseDate: '2024-02-02', + rating: 4.2, + genres: ['history'], + }, + ]; + + audibleServiceMock.getPopularAudiobooks.mockResolvedValue(popular); + audibleServiceMock.getNewReleases.mockResolvedValue(newReleases); + thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/path.jpg'); + thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(2); + prismaMock.audibleCache.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.audibleCache.upsert.mockResolvedValue({}); + prismaMock.audibleCache.findMany.mockResolvedValue([ + { asin: 'ASIN-1' }, + { asin: 'ASIN-2' }, + { asin: 'ASIN-3' }, + ]); + + const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor'); + const result = await processAudibleRefresh({ jobId: 'job-1' }); + + expect(result.success).toBe(true); + expect(result.popularSaved).toBe(2); + expect(result.newReleasesSaved).toBe(1); + expect(prismaMock.audibleCache.updateMany).toHaveBeenCalled(); + expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3); + expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-1', 'http://image/1'); + expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-3', 'http://image/3'); + expect(thumbnailCacheMock.cleanupUnusedThumbnails).toHaveBeenCalled(); + + const activeSet = thumbnailCacheMock.cleanupUnusedThumbnails.mock.calls[0][0] as Set; + expect(Array.from(activeSet).sort()).toEqual(['ASIN-1', 'ASIN-2', 'ASIN-3']); + }); + + it('rethrows fatal errors', async () => { + prismaMock.audibleCache.updateMany.mockRejectedValue(new Error('DB down')); + + const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor'); + await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down'); + }); +}); + + diff --git a/tests/processors/cleanup-seeded-torrents.processor.test.ts b/tests/processors/cleanup-seeded-torrents.processor.test.ts new file mode 100644 index 0000000..2941e79 --- /dev/null +++ b/tests/processors/cleanup-seeded-torrents.processor.test.ts @@ -0,0 +1,142 @@ +/** + * Component: Cleanup Seeded Torrents Processor Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ get: vi.fn() })); +const qbtMock = vi.hoisted(() => ({ + getTorrent: vi.fn(), + deleteTorrent: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + getQBittorrentService: async () => qbtMock, +})); + +describe('processCleanupSeededTorrents', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('skips when no indexer configuration is found', async () => { + configMock.get.mockResolvedValue(null); + + const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); + const result = await processCleanupSeededTorrents({ jobId: 'job-1' }); + + expect(result.skipped).toBe(true); + expect(prismaMock.request.findMany).not.toHaveBeenCalled(); + }); + + it('hard deletes orphaned SABnzbd requests', async () => { + configMock.get.mockResolvedValue( + JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 30 }]) + ); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + deletedAt: new Date(), + downloadHistory: [ + { + selected: true, + downloadStatus: 'completed', + indexerName: 'IndexerA', + nzbId: 'nzb-1', + torrentHash: null, + }, + ], + }, + ]); + prismaMock.request.delete.mockResolvedValue({}); + + const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); + const result = await processCleanupSeededTorrents({ jobId: 'job-2' }); + + expect(result.success).toBe(true); + expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-1' } }); + expect(qbtMock.getTorrent).not.toHaveBeenCalled(); + }); + + it('deletes torrents when seeding requirements are met with no shared downloads', async () => { + configMock.get.mockResolvedValue( + JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 30 }]) + ); + prismaMock.request.findMany + .mockResolvedValueOnce([ + { + id: 'req-2', + deletedAt: null, + downloadHistory: [ + { + selected: true, + downloadStatus: 'completed', + indexerName: 'IndexerA', + torrentHash: 'hash-1', + }, + ], + }, + ]) + .mockResolvedValueOnce([]); + + qbtMock.getTorrent.mockResolvedValue({ + name: 'Torrent', + seeding_time: 60 * 40, + }); + qbtMock.deleteTorrent.mockResolvedValue({}); + + const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); + const result = await processCleanupSeededTorrents({ jobId: 'job-3' }); + + expect(result.cleaned).toBe(1); + expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true); + }); + + it('keeps shared torrents and deletes soft-deleted request', async () => { + configMock.get.mockResolvedValue( + JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 10 }]) + ); + prismaMock.request.findMany + .mockResolvedValueOnce([ + { + id: 'req-3', + deletedAt: new Date(), + downloadHistory: [ + { + selected: true, + downloadStatus: 'completed', + indexerName: 'IndexerA', + torrentHash: 'hash-2', + }, + ], + }, + ]) + .mockResolvedValueOnce([{ id: 'req-4', status: 'downloaded' }]); + + qbtMock.getTorrent.mockResolvedValue({ + name: 'Torrent', + seeding_time: 60 * 20, + }); + prismaMock.request.delete.mockResolvedValue({}); + + const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor'); + const result = await processCleanupSeededTorrents({ jobId: 'job-4' }); + + expect(result.skipped).toBe(1); + expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-3' } }); + expect(qbtMock.deleteTorrent).not.toHaveBeenCalled(); + }); +}); + + diff --git a/tests/processors/download-torrent.processor.test.ts b/tests/processors/download-torrent.processor.test.ts new file mode 100644 index 0000000..3bf429d --- /dev/null +++ b/tests/processors/download-torrent.processor.test.ts @@ -0,0 +1,98 @@ +/** + * Component: Download Torrent Processor Tests + * Documentation: documentation/backend/services/jobs.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import { createJobQueueMock } from '../helpers/job-queue'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ get: vi.fn() })); +const jobQueueMock = createJobQueueMock(); +const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() })); +const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() })); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + getQBittorrentService: () => qbtMock, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + getSABnzbdService: () => sabMock, +})); + +describe('processDownloadTorrent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const payload = { + requestId: 'req-1', + audiobook: { id: 'a1', title: 'Book', author: 'Author' }, + torrent: { + indexer: 'Indexer', + title: 'Book - Author', + size: 50 * 1024 * 1024, + seeders: 10, + publishDate: new Date(), + downloadUrl: 'magnet:?xt=urn:btih:abc', + guid: 'guid-1', + format: 'M4B', + }, + jobId: 'job-1', + }; + + it('routes downloads to qBittorrent by default', async () => { + configMock.get.mockResolvedValue('qbittorrent'); + qbtMock.addTorrent.mockResolvedValue('hash-1'); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' }); + + const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor'); + const result = await processDownloadTorrent(payload); + + expect(result.success).toBe(true); + expect(qbtMock.addTorrent).toHaveBeenCalled(); + expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith( + 'req-1', + 'dh-1', + 'hash-1', + 'qbittorrent', + 3 + ); + }); + + it('routes downloads to SABnzbd when configured', async () => { + configMock.get.mockResolvedValue('sabnzbd'); + sabMock.addNZB.mockResolvedValue('nzb-1'); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' }); + + const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor'); + const result = await processDownloadTorrent(payload); + + expect(result.success).toBe(true); + expect(sabMock.addNZB).toHaveBeenCalled(); + expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith( + 'req-1', + 'dh-2', + 'nzb-1', + 'sabnzbd', + 3 + ); + }); +}); + + diff --git a/tests/processors/match-plex.processor.test.ts b/tests/processors/match-plex.processor.test.ts new file mode 100644 index 0000000..e50c5fd --- /dev/null +++ b/tests/processors/match-plex.processor.test.ts @@ -0,0 +1,190 @@ +/** + * Component: Match Library Processor Tests + * Documentation: documentation/phase3/README.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const libraryServiceMock = vi.hoisted(() => ({ searchItems: vi.fn() })); +const configMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + get: vi.fn(), + getPlexConfig: vi.fn(), +})); +const compareTwoStringsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/library', () => ({ + getLibraryService: async () => libraryServiceMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('string-similarity', () => ({ + compareTwoStrings: compareTwoStringsMock, +})); + +describe('processMatchPlex', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('completes request when no library results are found', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' }); + libraryServiceMock.searchItems.mockResolvedValue([]); + prismaMock.request.update.mockResolvedValue({}); + + const { processMatchPlex } = await import('@/lib/processors/match-plex.processor'); + const result = await processMatchPlex({ + requestId: 'req-1', + audiobookId: 'ab-1', + title: 'Missing Title', + author: 'Author', + jobId: 'job-1', + }); + + expect(result.matched).toBe(false); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'req-1' }, + data: expect.objectContaining({ status: 'completed' }), + }) + ); + expect(prismaMock.audiobook.update).not.toHaveBeenCalled(); + }); + + it('updates audiobook and request when a high-score match is found (plex)', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' }); + libraryServiceMock.searchItems.mockResolvedValue([ + { + id: 'item-1', + externalId: 'guid-1', + title: 'Best Match', + author: 'Author', + }, + ]); + compareTwoStringsMock.mockReturnValue(0.95); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + + const { processMatchPlex } = await import('@/lib/processors/match-plex.processor'); + const result = await processMatchPlex({ + requestId: 'req-2', + audiobookId: 'ab-2', + title: 'Best Match', + author: 'Author', + jobId: 'job-2', + }); + + expect(result.matched).toBe(true); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ab-2' }, + data: expect.objectContaining({ plexGuid: 'guid-1' }), + }) + ); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'req-2' }, + data: expect.objectContaining({ status: 'completed' }), + }) + ); + }); + + it('uses audiobookshelf IDs when backend mode is audiobookshelf', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue('abs-lib'); + libraryServiceMock.searchItems.mockResolvedValue([ + { + id: 'item-abs', + externalId: 'abs-1', + title: 'Shelf Match', + author: 'Author', + }, + ]); + compareTwoStringsMock.mockReturnValue(0.9); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + + const { processMatchPlex } = await import('@/lib/processors/match-plex.processor'); + const result = await processMatchPlex({ + requestId: 'req-3', + audiobookId: 'ab-3', + title: 'Shelf Match', + author: 'Author', + jobId: 'job-3', + }); + + expect(result.matched).toBe(true); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ absItemId: 'abs-1' }), + }) + ); + }); + + it('completes request without match when score is too low', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' }); + libraryServiceMock.searchItems.mockResolvedValue([ + { + id: 'item-low', + externalId: 'guid-low', + title: 'Low Match', + author: 'Author', + }, + ]); + compareTwoStringsMock.mockReturnValue(0.1); + prismaMock.request.update.mockResolvedValue({}); + + const { processMatchPlex } = await import('@/lib/processors/match-plex.processor'); + const result = await processMatchPlex({ + requestId: 'req-4', + audiobookId: 'ab-4', + title: 'Low Match', + author: 'Author', + jobId: 'job-4', + }); + + expect(result.matched).toBe(false); + expect(prismaMock.audiobook.update).not.toHaveBeenCalled(); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'completed' }), + }) + ); + }); + + it('marks request completed with error when matching fails', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: null }); + prismaMock.request.update.mockResolvedValue({}); + + const { processMatchPlex } = await import('@/lib/processors/match-plex.processor'); + const result = await processMatchPlex({ + requestId: 'req-5', + audiobookId: 'ab-5', + title: 'Error Title', + author: 'Author', + jobId: 'job-5', + }); + + expect(result.success).toBe(false); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'completed' }), + }) + ); + }); +}); + + diff --git a/tests/processors/monitor-download.processor.test.ts b/tests/processors/monitor-download.processor.test.ts new file mode 100644 index 0000000..bac6296 --- /dev/null +++ b/tests/processors/monitor-download.processor.test.ts @@ -0,0 +1,258 @@ +/** + * Component: Monitor Download Processor Tests + * Documentation: documentation/backend/services/jobs.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import { createJobQueueMock } from '../helpers/job-queue'; + +const prismaMock = createPrismaMock(); +const jobQueueMock = createJobQueueMock(); +const qbtMock = vi.hoisted(() => ({ + getTorrent: vi.fn(), + getDownloadProgress: vi.fn(), +})); +const sabMock = vi.hoisted(() => ({ + getNZB: vi.fn(), +})); +const configMock = vi.hoisted(() => ({ + getMany: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + getQBittorrentService: () => qbtMock, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + getSABnzbdService: () => sabMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +describe('processMonitorDownload', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('queues organize job when qBittorrent download completes', async () => { + qbtMock.getTorrent.mockResolvedValue({ + content_path: '/remote/done/Book', + save_path: '/remote/done', + name: 'Book', + }); + qbtMock.getDownloadProgress.mockReturnValue({ + percent: 100, + state: 'completed', + speed: 0, + eta: 0, + }); + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'true', + download_client_remote_path: '/remote/done', + download_client_local_path: '/downloads', + }); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-1', + audiobook: { id: 'a1' }, + deletedAt: null, + }); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + const result = await processMonitorDownload({ + requestId: 'req-1', + downloadHistoryId: 'dh-1', + downloadClientId: 'hash-1', + downloadClient: 'qbittorrent', + jobId: 'job-1', + }); + + expect(result.completed).toBe(true); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-1', + 'a1', + expect.stringMatching(/downloads[\\/]+Book/) + ); + }); + + it('re-schedules monitoring when download is still active', async () => { + qbtMock.getTorrent.mockResolvedValue({ + save_path: '/downloads', + name: 'Book', + }); + qbtMock.getDownloadProgress.mockReturnValue({ + percent: 45, + state: 'downloading', + speed: 100, + eta: 60, + }); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + const result = await processMonitorDownload({ + requestId: 'req-2', + downloadHistoryId: 'dh-2', + downloadClientId: 'hash-2', + downloadClient: 'qbittorrent', + jobId: 'job-2', + }); + + expect(result.completed).toBe(false); + expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith( + 'req-2', + 'dh-2', + 'hash-2', + 'qbittorrent', + 10 + ); + }); + + it('marks request failed when download fails', async () => { + qbtMock.getTorrent.mockResolvedValue({ + save_path: '/downloads', + name: 'Book', + }); + qbtMock.getDownloadProgress.mockReturnValue({ + percent: 20, + state: 'failed', + speed: 0, + eta: 0, + }); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + const result = await processMonitorDownload({ + requestId: 'req-3', + downloadHistoryId: 'dh-3', + downloadClientId: 'hash-3', + downloadClient: 'qbittorrent', + jobId: 'job-3', + }); + + expect(result.success).toBe(false); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + }) + ); + }); + + it('handles SABnzbd completion and queues organize job', async () => { + sabMock.getNZB.mockResolvedValue({ + nzbId: 'nzb-1', + size: 100, + progress: 1, + status: 'completed', + downloadSpeed: 0, + timeLeft: 0, + downloadPath: '/usenet/complete/Book', + }); + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-4', + audiobook: { id: 'a4' }, + deletedAt: null, + }); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + const result = await processMonitorDownload({ + requestId: 'req-4', + downloadHistoryId: 'dh-4', + downloadClientId: 'nzb-1', + downloadClient: 'sabnzbd', + jobId: 'job-4', + }); + + expect(result.completed).toBe(true); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-4', + 'a4', + '/usenet/complete/Book' + ); + }); + + it('does not mark request failed for transient NZB not found errors', async () => { + sabMock.getNZB.mockResolvedValue(null); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + await expect(processMonitorDownload({ + requestId: 'req-5', + downloadHistoryId: 'dh-5', + downloadClientId: 'nzb-missing', + downloadClient: 'sabnzbd', + jobId: 'job-5', + })).rejects.toThrow(/not found/i); + + expect(prismaMock.request.update).not.toHaveBeenCalled(); + }); + + it('marks request failed when download client is unsupported', async () => { + prismaMock.request.update.mockResolvedValue({}); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + await expect(processMonitorDownload({ + requestId: 'req-6', + downloadHistoryId: 'dh-6', + downloadClientId: 'id-6', + downloadClient: 'deluge', + jobId: 'job-6', + })).rejects.toThrow(/not supported/i); + + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + }) + ); + }); + + it('marks request failed when SABnzbd completion lacks a download path', async () => { + sabMock.getNZB.mockResolvedValue({ + nzbId: 'nzb-2', + size: 100, + progress: 1, + status: 'completed', + downloadSpeed: 0, + timeLeft: 0, + downloadPath: undefined, + }); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor'); + await expect(processMonitorDownload({ + requestId: 'req-7', + downloadHistoryId: 'dh-7', + downloadClientId: 'nzb-2', + downloadClient: 'sabnzbd', + jobId: 'job-7', + })).rejects.toThrow(/Download path not available/i); + + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + }) + ); + }); +}); + + diff --git a/tests/processors/monitor-rss-feeds.processor.test.ts b/tests/processors/monitor-rss-feeds.processor.test.ts new file mode 100644 index 0000000..bf66696 --- /dev/null +++ b/tests/processors/monitor-rss-feeds.processor.test.ts @@ -0,0 +1,63 @@ +/** + * Component: Monitor RSS Feeds Processor Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import { createJobQueueMock } from '../helpers/job-queue'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ get: vi.fn() })); +const jobQueueMock = createJobQueueMock(); +const prowlarrMock = vi.hoisted(() => ({ getAllRssFeeds: vi.fn() })); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + getProwlarrService: () => prowlarrMock, +})); + +describe('processMonitorRssFeeds', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('matches RSS items and queues search jobs', async () => { + configMock.get.mockResolvedValue( + JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]) + ); + + prowlarrMock.getAllRssFeeds.mockResolvedValue([ + { title: 'Great Book - Author Name' }, + ]); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + audiobook: { id: 'a1', title: 'Great Book', author: 'Author Name', audibleAsin: 'ASIN1' }, + }, + ]); + + const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor'); + const result = await processMonitorRssFeeds({ jobId: 'job-1' }); + + expect(result.success).toBe(true); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith( + 'req-1', + expect.objectContaining({ title: 'Great Book', author: 'Author Name' }) + ); + }); +}); + + diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts new file mode 100644 index 0000000..f50b77e --- /dev/null +++ b/tests/processors/organize-files.processor.test.ts @@ -0,0 +1,116 @@ +/** + * Component: Organize Files Processor Tests + * Documentation: documentation/phase3/file-organization.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const organizerMock = vi.hoisted(() => ({ organize: vi.fn() })); +const libraryServiceMock = vi.hoisted(() => ({ triggerLibraryScan: vi.fn() })); +const configMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + get: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/utils/file-organizer', () => ({ + getFileOrganizer: () => organizerMock, +})); + +vi.mock('@/lib/services/library', () => ({ + getLibraryService: () => libraryServiceMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +describe('processOrganizeFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('organizes files and triggers filesystem scan when enabled', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a1', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN1', + }); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.m4b'], + }); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockImplementation(async (key: string) => { + if (key === 'plex.trigger_scan_after_import') return 'true'; + if (key === 'plex_audiobook_library_id') return 'lib-1'; + return null; + }); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-1', + audiobookId: 'a1', + downloadPath: '/downloads/book', + jobId: 'job-1', + }); + + expect(result.success).toBe(true); + expect(libraryServiceMock.triggerLibraryScan).toHaveBeenCalledWith('lib-1'); + }); + + it('queues retry when a retryable error occurs', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a2', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN2', + }); + organizerMock.organize.mockResolvedValue({ + success: false, + targetPath: '', + filesMovedCount: 0, + errors: ['No audiobook files found in download'], + audioFiles: [], + }); + prismaMock.request.findFirst.mockResolvedValue({ + importAttempts: 0, + maxImportRetries: 3, + deletedAt: null, + }); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-2', + audiobookId: 'a2', + downloadPath: '/downloads/book', + jobId: 'job-2', + }); + + expect(result.success).toBe(false); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'awaiting_import' }), + }) + ); + }); +}); + + diff --git a/tests/processors/plex-recently-added.processor.test.ts b/tests/processors/plex-recently-added.processor.test.ts new file mode 100644 index 0000000..0ef72ad --- /dev/null +++ b/tests/processors/plex-recently-added.processor.test.ts @@ -0,0 +1,167 @@ +/** + * Component: Recently Added Processor Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const libraryServiceMock = vi.hoisted(() => ({ + getRecentlyAdded: vi.fn(), +})); +const configMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + getMany: vi.fn(), + get: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/library', () => ({ + getLibraryService: async () => libraryServiceMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: vi.fn(), +})); + +vi.mock('@/lib/services/audiobookshelf/api', () => ({ + triggerABSItemMatch: vi.fn(), +})); + +describe('processPlexRecentlyAddedCheck', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('skips when Plex configuration is missing', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getMany.mockResolvedValue({ + plex_url: '', + plex_token: '', + plex_audiobook_library_id: '', + }); + + const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor'); + const result = await processPlexRecentlyAddedCheck({ jobId: 'job-1' }); + + expect(result.skipped).toBe(true); + expect(prismaMock.plexLibrary.findUnique).not.toHaveBeenCalled(); + }); + + it('creates and updates recently added library items', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getMany.mockResolvedValue({ + plex_url: 'http://plex', + plex_token: 'token', + plex_audiobook_library_id: 'lib-1', + }); + configMock.get.mockResolvedValue('lib-1'); + libraryServiceMock.getRecentlyAdded.mockResolvedValue([ + { + id: 'rating-1', + externalId: 'guid-1', + title: 'New Item', + author: 'Author A', + addedAt: new Date(), + }, + { + id: 'rating-2', + externalId: 'guid-2', + title: 'Existing Item', + author: 'Author B', + addedAt: new Date(), + }, + ]); + + prismaMock.plexLibrary.findUnique.mockImplementation(async (query: any) => { + if (query.where.plexGuid === 'guid-2') { + return { id: 'existing-id', plexGuid: 'guid-2', author: 'Author B' }; + } + return null; + }); + prismaMock.plexLibrary.create.mockResolvedValue({}); + prismaMock.plexLibrary.update.mockResolvedValue({}); + prismaMock.request.findMany.mockResolvedValue([]); + + const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor'); + const result = await processPlexRecentlyAddedCheck({ jobId: 'job-2' }); + + expect(result.newCount).toBe(1); + expect(result.updatedCount).toBe(1); + expect(prismaMock.plexLibrary.create).toHaveBeenCalled(); + expect(prismaMock.plexLibrary.update).toHaveBeenCalled(); + }); + + it('matches requests and triggers ABS metadata match for audiobookshelf', async () => { + const matcher = await import('@/lib/utils/audiobook-matcher'); + const absApi = await import('@/lib/services/audiobookshelf/api'); + + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.getMany.mockResolvedValue({ + 'audiobookshelf.server_url': 'http://abs', + 'audiobookshelf.api_token': 'token', + 'audiobookshelf.library_id': 'abs-lib', + }); + configMock.get.mockResolvedValue('abs-lib'); + libraryServiceMock.getRecentlyAdded.mockResolvedValue([ + { + id: 'abs-1', + externalId: 'abs-item-1', + title: 'New ABS Item', + author: 'Author A', + addedAt: new Date(), + }, + ]); + prismaMock.plexLibrary.findUnique.mockResolvedValue(null); + prismaMock.plexLibrary.create.mockResolvedValue({}); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + status: 'downloaded', + audiobook: { + id: 'ab-1', + title: 'Match Me', + author: 'Author A', + narrator: 'Narrator A', + audibleAsin: 'ASIN-ABS', + }, + }, + ]); + + (matcher.findPlexMatch as ReturnType).mockResolvedValue({ + plexGuid: 'abs-item-1', + plexRatingKey: 'rating-abs', + title: 'Match Me', + author: 'Author A', + }); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + + const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor'); + const result = await processPlexRecentlyAddedCheck({ jobId: 'job-3' }); + + expect(result.matchedDownloads).toBe(1); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ absItemId: 'abs-item-1' }), + }) + ); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'available' }), + }) + ); + expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN-ABS'); + }); +}); + + diff --git a/tests/processors/retry-failed-imports.processor.test.ts b/tests/processors/retry-failed-imports.processor.test.ts new file mode 100644 index 0000000..50bd70d --- /dev/null +++ b/tests/processors/retry-failed-imports.processor.test.ts @@ -0,0 +1,329 @@ +/** + * Component: Retry Failed Imports Processor Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import { createJobQueueMock } from '../helpers/job-queue'; + +const prismaMock = createPrismaMock(); +const jobQueueMock = createJobQueueMock(); +const configMock = vi.hoisted(() => ({ + getMany: vi.fn(), + get: vi.fn(), +})); +const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); +const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + getQBittorrentService: () => qbtMock, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + getSABnzbdService: () => sabnzbdMock, +})); + +describe('processRetryFailedImports', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('queues organize jobs using download client paths', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + audiobook: { id: 'a1', title: 'Book' }, + downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book' }], + }, + ]); + + qbtMock.getTorrent.mockResolvedValue({ + save_path: '/downloads', + name: 'Book', + }); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-1' }); + + expect(result.success).toBe(true); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-1', + 'a1', + '/downloads/Book' + ); + }); + + it('returns early when no requests await import', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + prismaMock.request.findMany.mockResolvedValue([]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({}); + + expect(result.success).toBe(true); + expect(result.triggered).toBe(0); + expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled(); + }); + + it('skips requests missing download history', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-2', + audiobook: { id: 'a2', title: 'Book' }, + downloadHistory: [], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-2' }); + + expect(result.skipped).toBe(1); + expect(result.triggered).toBe(0); + }); + + it('falls back to configured download dir when qBittorrent lookup fails', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'true', + download_client_remote_path: '/remote', + download_client_local_path: '/downloads', + }); + configMock.get.mockResolvedValue('/remote'); + + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-3', + audiobook: { id: 'a3', title: 'Book' }, + downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book' }], + }, + ]); + + qbtMock.getTorrent.mockRejectedValue(new Error('not found')); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-3' }); + + expect(result.triggered).toBe(1); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-3', + 'a3', + path.join('/downloads', 'Book') + ); + }); + + it('uses SABnzbd download path when available', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'true', + download_client_remote_path: '/remote/nzb', + download_client_local_path: '/downloads', + }); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-4', + audiobook: { id: 'a4', title: 'Book' }, + downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book' }], + }, + ]); + + sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/remote/nzb/Book' }); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-4' }); + + expect(result.triggered).toBe(1); + expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith( + 'req-4', + 'a4', + path.join('/downloads', 'Book') + ); + }); + + it('skips SABnzbd retries when download dir is missing', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + configMock.get.mockResolvedValue(null); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-5', + audiobook: { id: 'a5', title: 'Book' }, + downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book' }], + }, + ]); + + sabnzbdMock.getNZB.mockResolvedValue(null); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-5' }); + + expect(result.skipped).toBe(1); + expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled(); + }); + + it('skips requests with no client identifiers or names', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-6', + audiobook: { id: 'a6', title: 'Book' }, + downloadHistory: [{}], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-6' }); + + expect(result.skipped).toBe(1); + expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled(); + }); + + it('tracks skipped requests when organize job fails', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-7', + audiobook: { id: 'a7', title: 'Book' }, + downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book' }], + }, + ]); + qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' }); + jobQueueMock.addOrganizeJob.mockRejectedValue(new Error('queue down')); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-7' }); + + expect(result.triggered).toBe(0); + expect(result.skipped).toBe(1); + }); + + it('skips qBittorrent fallbacks when torrent name is missing', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-8', + audiobook: { id: 'a8', title: 'Book' }, + downloadHistory: [{ torrentHash: 'hash-8' }], + }, + ]); + qbtMock.getTorrent.mockRejectedValue(new Error('not found')); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-8' }); + + expect(result.triggered).toBe(0); + expect(result.skipped).toBe(1); + expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled(); + }); + + it('skips qBittorrent fallbacks when download_dir is not configured', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + configMock.get.mockResolvedValue(null); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-9', + audiobook: { id: 'a9', title: 'Book' }, + downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book' }], + }, + ]); + qbtMock.getTorrent.mockRejectedValue(new Error('not found')); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-9' }); + + expect(result.triggered).toBe(0); + expect(result.skipped).toBe(1); + }); + + it('skips SABnzbd retries when the client throws', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-10', + audiobook: { id: 'a10', title: 'Book' }, + downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book' }], + }, + ]); + + sabnzbdMock.getNZB.mockRejectedValue(new Error('sab down')); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-10' }); + + expect(result.triggered).toBe(0); + expect(result.skipped).toBe(1); + }); + + it('skips requests without download_dir when no client identifiers exist', async () => { + configMock.getMany.mockResolvedValue({ + download_client_remote_path_mapping_enabled: 'false', + download_client_remote_path: '', + download_client_local_path: '', + }); + configMock.get.mockResolvedValue(null); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-11', + audiobook: { id: 'a11', title: 'Book' }, + downloadHistory: [{ torrentName: 'Book' }], + }, + ]); + + const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor'); + const result = await processRetryFailedImports({ jobId: 'job-11' }); + + expect(result.triggered).toBe(0); + expect(result.skipped).toBe(1); + }); +}); + + diff --git a/tests/processors/retry-missing-torrents.processor.test.ts b/tests/processors/retry-missing-torrents.processor.test.ts new file mode 100644 index 0000000..e11a1c1 --- /dev/null +++ b/tests/processors/retry-missing-torrents.processor.test.ts @@ -0,0 +1,45 @@ +/** + * Component: Retry Missing Torrents Processor Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import { createJobQueueMock } from '../helpers/job-queue'; + +const prismaMock = createPrismaMock(); +const jobQueueMock = createJobQueueMock(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +describe('processRetryMissingTorrents', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('queues search jobs for awaiting_search requests', async () => { + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + audiobook: { id: 'a1', title: 'Book', author: 'Author', audibleAsin: 'ASIN1' }, + }, + ]); + + const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor'); + const result = await processRetryMissingTorrents({ jobId: 'job-1' }); + + expect(result.success).toBe(true); + expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith( + 'req-1', + expect.objectContaining({ id: 'a1', title: 'Book', author: 'Author' }) + ); + }); +}); + + diff --git a/tests/processors/scan-plex.processor.test.ts b/tests/processors/scan-plex.processor.test.ts new file mode 100644 index 0000000..ec3c7c5 --- /dev/null +++ b/tests/processors/scan-plex.processor.test.ts @@ -0,0 +1,251 @@ +/** + * Component: Library Scan Processor Tests + * Documentation: documentation/backend/services/jobs.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const libraryServiceMock = vi.hoisted(() => ({ getLibraryItems: vi.fn() })); +const configMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + getPlexConfig: vi.fn(), + get: vi.fn(), +})); + +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: vi.fn(), +})); + +vi.mock('@/lib/services/audiobookshelf/api', () => ({ + triggerABSItemMatch: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/library', () => ({ + getLibraryService: () => libraryServiceMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +describe('processScanPlex', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates and updates library items, matches requests', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + libraryId: 'lib-1', + machineIdentifier: 'machine', + }); + + libraryServiceMock.getLibraryItems.mockResolvedValue([ + { + id: 'rating-1', + externalId: 'guid-1', + title: 'New Book', + author: 'Author', + addedAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'rating-2', + externalId: 'guid-2', + title: 'Existing Book', + author: 'Author', + addedAt: new Date(), + updatedAt: new Date(), + }, + ]); + + prismaMock.plexLibrary.findFirst.mockImplementation(async (query: any) => { + if (query.where.plexGuid === 'guid-2') { + return { id: 'existing-id', plexGuid: 'guid-2' }; + } + return null; + }); + prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-1' }); + prismaMock.plexLibrary.update.mockResolvedValue({}); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + prismaMock.audiobook.findMany.mockResolvedValue([]); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-1', + status: 'downloaded', + audiobook: { + id: 'a1', + title: 'New Book', + author: 'Author', + narrator: null, + audibleAsin: 'ASIN1', + }, + }, + ]); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + + const matcher = await import('@/lib/utils/audiobook-matcher'); + vi.spyOn(matcher, 'findPlexMatch').mockResolvedValue({ + plexGuid: 'guid-1', + plexRatingKey: 'rating-1', + title: 'New Book', + author: 'Author', + }); + + const { processScanPlex } = await import('@/lib/processors/scan-plex.processor'); + const result = await processScanPlex({ jobId: 'job-1' }); + + expect(result.success).toBe(true); + expect(prismaMock.plexLibrary.create).toHaveBeenCalled(); + expect(prismaMock.plexLibrary.update).toHaveBeenCalled(); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'available' }), + }) + ); + }); + + it('throws when audiobookshelf library is not configured', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue(null); + + const { processScanPlex } = await import('@/lib/processors/scan-plex.processor'); + + await expect(processScanPlex({ jobId: 'job-2' })).rejects.toThrow( + 'Audiobookshelf library not configured' + ); + expect(libraryServiceMock.getLibraryItems).not.toHaveBeenCalled(); + }); + + it('removes stale items and resets linked audiobooks and requests', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + libraryId: 'lib-1', + machineIdentifier: 'machine', + }); + + libraryServiceMock.getLibraryItems.mockResolvedValue([ + { + id: 'rating-1', + externalId: 'guid-1', + title: 'Current Book', + author: 'Author', + addedAt: new Date(), + updatedAt: new Date(), + }, + ]); + + prismaMock.plexLibrary.findFirst.mockResolvedValue(null); + prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-1' }); + prismaMock.plexLibrary.findMany + .mockResolvedValueOnce([{ id: 'stale-1', plexGuid: 'stale-guid', title: 'Stale Book' }]) + .mockResolvedValueOnce([{ plexGuid: 'guid-1' }]); + prismaMock.plexLibrary.delete.mockResolvedValue({}); + prismaMock.audiobook.findMany + .mockResolvedValueOnce([ + { + id: 'ab-1', + title: 'Stale Book', + requests: [{ id: 'req-1', status: 'available' }], + }, + ]) + .mockResolvedValueOnce([ + { + id: 'ab-valid', + title: 'Valid Book', + plexGuid: 'guid-1', + absItemId: null, + requests: [], + }, + { + id: 'ab-orphan', + title: 'Orphaned Book', + plexGuid: null, + absItemId: 'missing-guid', + requests: [{ id: 'req-2', status: 'available' }], + }, + ]); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.request.findMany.mockResolvedValue([]); + + const matcher = await import('@/lib/utils/audiobook-matcher'); + (matcher.findPlexMatch as ReturnType).mockResolvedValue(null); + + const { processScanPlex } = await import('@/lib/processors/scan-plex.processor'); + const result = await processScanPlex({ jobId: 'job-3' }); + + expect(result.success).toBe(true); + expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'stale-1' } }); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ab-orphan' }, + data: expect.objectContaining({ plexGuid: null, absItemId: null }), + }) + ); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'downloaded' }), + }) + ); + }); + + it('matches audiobookshelf requests and triggers metadata match', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue('abs-lib'); + + libraryServiceMock.getLibraryItems.mockResolvedValue([]); + + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + prismaMock.audiobook.findMany.mockResolvedValue([]); + prismaMock.request.findMany.mockResolvedValue([ + { + id: 'req-abs', + status: 'downloaded', + audiobook: { + id: 'abs-audio', + title: 'ABS Title', + author: 'ABS Author', + narrator: 'Narrator', + audibleAsin: 'ASIN123', + }, + }, + ]); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + + const matcher = await import('@/lib/utils/audiobook-matcher'); + (matcher.findPlexMatch as ReturnType).mockResolvedValue({ + plexGuid: 'abs-item-1', + plexRatingKey: 'rating-abs', + title: 'ABS Title', + author: 'ABS Author', + }); + + const absApi = await import('@/lib/services/audiobookshelf/api'); + + const { processScanPlex } = await import('@/lib/processors/scan-plex.processor'); + const result = await processScanPlex({ jobId: 'job-4' }); + + expect(result.success).toBe(true); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ absItemId: 'abs-item-1' }), + }) + ); + expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN123'); + }); +}); + + diff --git a/tests/processors/search-indexers.processor.test.ts b/tests/processors/search-indexers.processor.test.ts new file mode 100644 index 0000000..6e753b3 --- /dev/null +++ b/tests/processors/search-indexers.processor.test.ts @@ -0,0 +1,128 @@ +/** + * Component: Search Indexers Processor Tests + * Documentation: documentation/backend/services/jobs.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import { createJobQueueMock } from '../helpers/job-queue'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ get: vi.fn() })); +const jobQueueMock = createJobQueueMock(); +const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() })); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/integrations/prowlarr.service', () => ({ + getProwlarrService: () => prowlarrMock, +})); + +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => ({ getRuntime: vi.fn().mockResolvedValue(null) }), +})); + +describe('processSearchIndexers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('marks request awaiting_search when no results found', async () => { + configMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]); + } + return null; + }); + prowlarrMock.search.mockResolvedValue([]); + prismaMock.request.update.mockResolvedValue({}); + + const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor'); + const result = await processSearchIndexers({ + requestId: 'req-1', + audiobook: { id: 'a1', title: 'Book', author: 'Author' }, + jobId: 'job-1', + }); + + expect(result.success).toBe(false); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'awaiting_search' }), + }) + ); + }); + + it('queues download job when results are ranked', async () => { + configMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]); + } + if (key === 'indexer_flag_config') { + return JSON.stringify([]); + } + return null; + }); + + prowlarrMock.search.mockResolvedValue([ + { + indexer: 'Indexer', + indexerId: 1, + title: 'Book - Author', + size: 50 * 1024 * 1024, + seeders: 10, + publishDate: new Date(), + downloadUrl: 'magnet:?xt=urn:btih:abc', + guid: 'guid-1', + format: 'M4B', + }, + ]); + + prismaMock.request.update.mockResolvedValue({}); + + const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor'); + const result = await processSearchIndexers({ + requestId: 'req-2', + audiobook: { id: 'a2', title: 'Book', author: 'Author' }, + jobId: 'job-2', + }); + + expect(result.success).toBe(true); + expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith( + 'req-2', + { id: 'a2', title: 'Book', author: 'Author' }, + expect.objectContaining({ title: 'Book - Author' }) + ); + }); + + it('fails when no indexers are configured', async () => { + configMock.get.mockResolvedValue(null); + prismaMock.request.update.mockResolvedValue({}); + + const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor'); + await expect( + processSearchIndexers({ + requestId: 'req-3', + audiobook: { id: 'a3', title: 'Book', author: 'Author' }, + jobId: 'job-3', + }) + ).rejects.toThrow('No indexers configured'); + + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + }) + ); + }); +}); + + diff --git a/tests/services/audiobookshelf-api.test.ts b/tests/services/audiobookshelf-api.test.ts new file mode 100644 index 0000000..9da0a79 --- /dev/null +++ b/tests/services/audiobookshelf-api.test.ts @@ -0,0 +1,147 @@ +/** + * Component: Audiobookshelf API Client Tests + * Documentation: documentation/features/audiobookshelf-integration.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + absRequest, + getABSLibraries, + getABSLibraryItems, + getABSRecentItems, + getABSServerInfo, + searchABSItems, + triggerABSItemMatch, + triggerABSScan, +} from '@/lib/services/audiobookshelf/api'; + +const configServiceMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const fetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('Audiobookshelf API client', () => { + beforeEach(() => { + vi.clearAllMocks(); + configServiceMock.get.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + it('throws when Audiobookshelf config is missing', async () => { + configServiceMock.get.mockResolvedValue(null); + + await expect(absRequest('/status')).rejects.toThrow('Audiobookshelf not configured'); + }); + + it('returns parsed JSON for successful requests', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ version: '2.0.0', name: 'ABS' }), + }); + + const info = await getABSServerInfo(); + + expect(info).toEqual({ version: '2.0.0', name: 'ABS' }); + expect(fetchMock).toHaveBeenCalledWith('http://abs/api/status', expect.any(Object)); + }); + + it('maps library responses and search queries', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ libraries: [{ id: 'lib-1' }] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 'item-1' }] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ results: [{ id: 'recent-1' }] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ book: [{ id: 'result-1' }] }), + }); + + expect(await getABSLibraries()).toEqual([{ id: 'lib-1' }]); + expect(await getABSLibraryItems('lib-1')).toEqual([{ id: 'item-1' }]); + expect(await getABSRecentItems('lib-1', 5)).toEqual([{ id: 'recent-1' }]); + expect(await searchABSItems('lib-1', 'hello world')).toEqual([{ id: 'result-1' }]); + + expect(fetchMock.mock.calls[1][0]).toBe('http://abs/api/libraries/lib-1/items'); + expect(fetchMock.mock.calls[2][0]).toBe('http://abs/api/libraries/lib-1/items?sort=addedAt&desc=1&limit=5'); + expect(fetchMock.mock.calls[3][0]).toBe('http://abs/api/libraries/lib-1/search?q=hello%20world'); + }); + + it('triggers library scan using plain text responses', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: true, + text: async () => 'OK', + }); + + await triggerABSScan('lib-1'); + + expect(fetchMock).toHaveBeenCalledWith('http://abs/api/libraries/lib-1/scan', expect.objectContaining({ + method: 'POST', + })); + }); + + it('includes ASIN overrides in metadata match requests', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + + await triggerABSItemMatch('item-1', 'ASIN123'); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toEqual({ + provider: 'audible', + asin: 'ASIN123', + overrideDefaults: true, + }); + }); + + it('suppresses errors when metadata match fails', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Boom', + }); + + await expect(triggerABSItemMatch('item-1', 'ASIN123')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/services/auth/local-auth-provider.test.ts b/tests/services/auth/local-auth-provider.test.ts new file mode 100644 index 0000000..6f479b3 --- /dev/null +++ b/tests/services/auth/local-auth-provider.test.ts @@ -0,0 +1,303 @@ +/** + * Component: Local Auth Provider Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ get: vi.fn() })); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((value: string) => `enc:${value}`), + decrypt: vi.fn((value: string) => value.replace('enc:', '')), +})); + +const bcryptCompare = vi.fn(); +const bcryptHash = vi.fn(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('bcrypt', () => ({ + default: { compare: bcryptCompare, hash: bcryptHash }, + compare: bcryptCompare, + hash: bcryptHash, +})); + +describe('LocalAuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('logs in approved local users with valid password', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-1', + plexId: 'local-user', + plexUsername: 'user', + role: 'user', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'approved', + deletedAt: null, + }); + prismaMock.user.update.mockResolvedValue({}); + bcryptCompare.mockResolvedValue(true); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.handleCallback({ username: 'user', password: 'pass' }); + + expect(result.success).toBe(true); + expect(result.user?.authProvider).toBe('local'); + expect(result.tokens?.accessToken).toBeTruthy(); + expect(result.tokens?.refreshToken).toBeTruthy(); + }); + + it('rejects login when credentials are missing', async () => { + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.handleCallback({ username: '', password: '' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Username and password required'); + }); + + it('blocks login when approval is pending', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-2', + plexId: 'local-user', + plexUsername: 'user', + role: 'user', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'pending_approval', + deletedAt: null, + }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.handleCallback({ username: 'user', password: 'pass' }); + + expect(result.success).toBe(false); + expect(result.requiresApproval).toBe(true); + }); + + it('rejects login when account is rejected', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-2b', + plexId: 'local-user', + plexUsername: 'user', + role: 'user', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'rejected', + deletedAt: null, + }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.handleCallback({ username: 'user', password: 'pass' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('rejected'); + }); + + it('rejects login with invalid password', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-3', + plexId: 'local-user', + plexUsername: 'user', + role: 'user', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'approved', + deletedAt: null, + }); + bcryptCompare.mockResolvedValue(false); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.handleCallback({ username: 'user', password: 'bad' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/invalid username or password/i); + }); + + it('rejects login when password hash cannot be decrypted', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-4', + plexId: 'local-user', + plexUsername: 'user', + role: 'user', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'approved', + deletedAt: null, + }); + encryptionMock.decrypt.mockImplementationOnce(() => { + throw new Error('decrypt failed'); + }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.handleCallback({ username: 'user', password: 'pass' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/invalid username or password/i); + }); + + it('rejects login when user is not found', async () => { + prismaMock.user.findFirst.mockResolvedValue(null); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.handleCallback({ username: 'user', password: 'pass' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/invalid username or password/i); + }); + + it('blocks registration when disabled', async () => { + configMock.get.mockResolvedValueOnce('false'); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'user', password: 'password123' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/registration is disabled/i); + }); + + it('rejects short usernames or passwords on registration', async () => { + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + + let result = await provider.register({ username: 'ab', password: 'password123' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Username'); + + result = await provider.register({ username: 'user', password: 'short' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Password'); + }); + + it('rejects registration when username is taken', async () => { + configMock.get.mockResolvedValueOnce('true'); + prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'user', password: 'password123' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Username already taken'); + }); + + it('creates admin user on first registration', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + configMock.get.mockResolvedValueOnce('false'); // no admin approval + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.count.mockResolvedValue(0); + prismaMock.user.create.mockResolvedValue({ + id: 'user-1', + plexId: 'local-user', + plexUsername: 'user', + role: 'admin', + }); + bcryptHash.mockResolvedValue('hash'); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'user', password: 'password123' }); + + expect(result.success).toBe(true); + expect(result.user?.role).toBe('admin'); + }); + + it('returns pending approval when admin approval is required', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + configMock.get.mockResolvedValueOnce('true'); // admin approval required + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.count.mockResolvedValue(2); + prismaMock.user.create.mockResolvedValue({ + id: 'user-11', + plexId: 'local-user', + plexUsername: 'user', + role: 'user', + }); + bcryptHash.mockResolvedValue('hash'); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'user', password: 'password123' }); + + expect(result.success).toBe(false); + expect(result.requiresApproval).toBe(true); + }); + + it('returns false for non-local or missing users during access validation', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const missing = await provider.validateAccess({ id: 'user-12', username: 'x' }); + + expect(missing).toBe(false); + + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-13', + authProvider: 'plex', + deletedAt: null, + registrationStatus: 'approved', + }); + + const notLocal = await provider.validateAccess({ id: 'user-13', username: 'x' }); + expect(notLocal).toBe(false); + }); + + it('returns null for refresh token placeholder', async () => { + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + + const tokens = await provider.refreshToken('refresh'); + expect(tokens).toBeNull(); + }); + + it('rejects access for deleted or unapproved users', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-4', + authProvider: 'local', + deletedAt: new Date(), + registrationStatus: 'approved', + }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const deletedAccess = await provider.validateAccess({ id: 'user-4', username: 'x' }); + + expect(deletedAccess).toBe(false); + + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-5', + authProvider: 'local', + deletedAt: null, + registrationStatus: 'pending_approval', + }); + + const pendingAccess = await provider.validateAccess({ id: 'user-5', username: 'x' }); + expect(pendingAccess).toBe(false); + }); +}); + + + diff --git a/tests/services/auth/oidc-auth-provider.test.ts b/tests/services/auth/oidc-auth-provider.test.ts new file mode 100644 index 0000000..43ecc56 --- /dev/null +++ b/tests/services/auth/oidc-auth-provider.test.ts @@ -0,0 +1,375 @@ +/** + * Component: OIDC Auth Provider Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ get: vi.fn() })); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((value: string) => value), + decrypt: vi.fn((value: string) => value), +})); + +const clientMock = { + authorizationUrl: vi.fn(), + callback: vi.fn(), + userinfo: vi.fn(), +}; + +const issuerMock = { + Client: class { + constructor() { + return clientMock; + } + }, +}; + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +const schedulerMock = vi.hoisted(() => ({ + triggerJobNow: vi.fn(), +})); + +vi.mock('@/lib/services/scheduler.service', () => ({ + getSchedulerService: () => schedulerMock, +})); + +vi.mock('@/lib/utils/jwt', () => ({ + generateAccessToken: vi.fn(() => 'access-token'), + generateRefreshToken: vi.fn(() => 'refresh-token'), +})); + +vi.mock('@/lib/utils/url', () => ({ + getBaseUrl: () => 'http://localhost:3030', +})); + +vi.mock('openid-client', () => ({ + Issuer: { + discover: vi.fn(async () => issuerMock), + }, + generators: { + state: vi.fn(() => 'state-1'), + nonce: vi.fn(() => 'nonce-1'), + codeVerifier: vi.fn(() => 'verifier-1'), + codeChallenge: vi.fn(() => 'challenge-1'), + }, +})); + +describe('OIDCAuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PUBLIC_URL = 'http://localhost:3030'; + }); + + const setConfig = (values: Record) => { + configMock.get.mockImplementation(async (key: string) => values[key] ?? null); + }; + + it('returns error when code or state is missing', async () => { + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const result = await provider.handleCallback({}); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/missing authorization code or state/i); + }); + + it('returns error when provider sends an error', async () => { + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const result = await provider.handleCallback({ error: 'access_denied' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('access_denied'); + }); + + it('returns error for invalid callback state', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + }); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const result = await provider.handleCallback({ code: 'code', state: 'missing' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/invalid or expired state/i); + }); + + it('initiates login and returns redirect URL with state', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const result = await provider.initiateLogin(); + + expect(result.redirectUrl).toBe('https://issuer/auth'); + expect(result.state).toBe('state-1'); + }); + + it('throws when OIDC is not fully configured', async () => { + setConfig({ + 'oidc.issuer_url': null, + 'oidc.client_id': null, + 'oidc.client_secret': null, + }); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + + await expect(provider.initiateLogin()).rejects.toThrow('Failed to initiate OIDC authentication'); + }); + + it('blocks access when group claim is missing', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'group_claim', + 'oidc.access_group_claim': 'groups', + 'oidc.access_group_value': 'readmeabook-users', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + clientMock.callback.mockResolvedValue({ access_token: 'token' }); + clientMock.userinfo.mockResolvedValue({ sub: 'sub-1', groups: ['other-group'] }); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + const result = await provider.handleCallback({ code: 'code', state: 'state-1' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/do not have access/i); + }); + + it('allows access for allowed list emails and returns tokens', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'allowed_list', + 'oidc.allowed_emails': JSON.stringify(['user@example.com']), + 'oidc.allowed_usernames': JSON.stringify([]), + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + clientMock.callback.mockResolvedValue({ access_token: 'token' }); + clientMock.userinfo.mockResolvedValue({ sub: 'sub-3', email: 'user@example.com' }); + + prismaMock.user.count.mockResolvedValue(1); + prismaMock.user.upsert.mockResolvedValue({ + id: 'user-1', + plexUsername: 'user@example.com', + plexEmail: 'user@example.com', + role: 'user', + avatarUrl: null, + }); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + const result = await provider.handleCallback({ code: 'code', state: 'state-1' }); + + expect(result.success).toBe(true); + expect(result.tokens?.accessToken).toBe('access-token'); + }); + + it('returns requiresApproval for admin approval flow', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'admin_approval', + 'oidc.provider_name': 'TestOIDC', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + clientMock.callback.mockResolvedValue({ access_token: 'token' }); + clientMock.userinfo.mockResolvedValue({ sub: 'sub-2', preferred_username: 'user' }); + + prismaMock.user.count.mockResolvedValue(2); + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.create.mockResolvedValue({}); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + const result = await provider.handleCallback({ code: 'code', state: 'state-1' }); + + expect(result.success).toBe(false); + expect(result.requiresApproval).toBe(true); + }); + + it('bypasses approval for the first admin user', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'admin_approval', + 'oidc.provider_name': 'TestOIDC', + 'oidc.admin_claim_enabled': 'true', + 'oidc.admin_claim_name': 'groups', + 'oidc.admin_claim_value': 'admins', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + clientMock.callback.mockResolvedValue({ access_token: 'token' }); + clientMock.userinfo.mockResolvedValue({ sub: 'sub-4', preferred_username: 'first', groups: ['admins'] }); + + prismaMock.user.count.mockResolvedValue(0); + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.upsert.mockResolvedValue({ + id: 'user-2', + plexUsername: 'first', + plexEmail: null, + role: 'admin', + avatarUrl: null, + }); + prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'sched-1' }); + prismaMock.configuration.upsert.mockResolvedValue({}); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + const result = await provider.handleCallback({ code: 'code', state: 'state-1' }); + + expect(result.success).toBe(true); + expect(result.user?.isAdmin).toBe(true); + expect(schedulerMock.triggerJobNow).toHaveBeenCalled(); + }); + + it('blocks pending and rejected users during admin approval', async () => { + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + 'oidc.access_control_method': 'admin_approval', + }); + + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + clientMock.callback.mockResolvedValue({ access_token: 'token' }); + clientMock.userinfo.mockResolvedValue({ sub: 'sub-5', preferred_username: 'pending' }); + + prismaMock.user.count.mockResolvedValue(2); + prismaMock.user.findFirst.mockResolvedValue({ registrationStatus: 'pending_approval' }); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + await provider.initiateLogin(); + const pending = await provider.handleCallback({ code: 'code', state: 'state-1' }); + + expect(pending.success).toBe(false); + expect(pending.requiresApproval).toBe(true); + + prismaMock.user.findFirst.mockResolvedValue({ registrationStatus: 'rejected' }); + await provider.initiateLogin(); + const rejected = await provider.handleCallback({ code: 'code', state: 'state-1' }); + + expect(rejected.success).toBe(false); + expect(rejected.error).toContain('rejected'); + }); + + it('returns false when access validation fails', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-3', + authProvider: 'oidc', + registrationStatus: 'pending_approval', + }); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const result = await provider.validateAccess({ id: 'user-3', username: 'user', isAdmin: false, authProvider: 'oidc' }); + + expect(result).toBe(false); + }); + + it('returns true when access validation succeeds', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-4', + authProvider: 'oidc', + registrationStatus: 'approved', + }); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const result = await provider.validateAccess({ id: 'user-4', username: 'user', isAdmin: false, authProvider: 'oidc' }); + + expect(result).toBe(true); + }); + + it('returns false when access validation throws', async () => { + prismaMock.user.findUnique.mockRejectedValue(new Error('db down')); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const result = await provider.validateAccess({ id: 'user-5', username: 'user', isAdmin: false, authProvider: 'oidc' }); + + expect(result).toBe(false); + }); + + it('expires old flow states during login', async () => { + vi.useFakeTimers(); + const start = new Date('2024-01-01T00:00:00Z'); + vi.setSystemTime(start); + + setConfig({ + 'oidc.issuer_url': 'https://issuer', + 'oidc.client_id': 'client', + 'oidc.client_secret': 'secret', + }); + clientMock.authorizationUrl.mockReturnValue('https://issuer/auth'); + + // Make generators return different values for each call + const { generators } = await import('openid-client'); + (generators.state as any) + .mockReturnValueOnce('state-1') + .mockReturnValueOnce('state-2'); + (generators.nonce as any) + .mockReturnValueOnce('nonce-1') + .mockReturnValueOnce('nonce-2'); + (generators.codeVerifier as any) + .mockReturnValueOnce('verifier-1') + .mockReturnValueOnce('verifier-2'); + (generators.codeChallenge as any) + .mockReturnValueOnce('challenge-1') + .mockReturnValueOnce('challenge-2'); + + const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider'); + const provider = new OIDCAuthProvider(); + const first = await provider.initiateLogin(); + + vi.setSystemTime(new Date(start.getTime() + 10 * 60 * 1000 + 1)); + await provider.initiateLogin(); + + const result = await provider.handleCallback({ code: 'code', state: first.state }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Invalid or expired state/i); + + vi.useRealTimers(); + }); +}); + + diff --git a/tests/services/auth/plex-auth-provider.test.ts b/tests/services/auth/plex-auth-provider.test.ts new file mode 100644 index 0000000..7c477d1 --- /dev/null +++ b/tests/services/auth/plex-auth-provider.test.ts @@ -0,0 +1,232 @@ +/** + * Component: Plex Auth Provider Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const configMock = vi.hoisted(() => ({ + getPlexConfig: vi.fn(), +})); +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((value: string) => `enc:${value}`), + decrypt: vi.fn((value: string) => value.replace('enc:', '')), +})); +const plexServiceMock = vi.hoisted(() => ({ + requestPin: vi.fn(), + getOAuthUrl: vi.fn(), + checkPin: vi.fn(), + getUserInfo: vi.fn(), + verifyServerAccess: vi.fn(), + getHomeUsers: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => plexServiceMock, +})); + +describe('PlexAuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('initiates login and returns OAuth URL', async () => { + process.env.PLEX_OAUTH_CALLBACK_URL = 'http://app/callback'; + plexServiceMock.requestPin.mockResolvedValue({ id: 42, code: 'CODE' }); + plexServiceMock.getOAuthUrl.mockReturnValue('http://plex/oauth'); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const result = await provider.initiateLogin(); + + expect(result.redirectUrl).toBe('http://plex/oauth'); + expect(result.pinId).toBe('42'); + }); + + it('returns error when PIN authorization is still pending', async () => { + plexServiceMock.checkPin.mockResolvedValue(null); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const result = await provider.handleCallback({ pinId: '123' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/waiting for user authorization/i); + }); + + it('returns error when pinId is missing', async () => { + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const result = await provider.handleCallback({}); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/missing pin id/i); + }); + + it('returns error when Plex server is not configured', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ + id: 1, + username: 'user', + authToken: 'token', + }); + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: null, + authToken: 'token', + libraryId: null, + machineIdentifier: null, + }); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const result = await provider.handleCallback({ pinId: '123' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/plex server is not configured/i); + }); + + it('returns profile selection when multiple home users are present', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ + id: 1, + username: 'user', + authToken: 'token', + }); + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + libraryId: 'lib', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(true); + plexServiceMock.getHomeUsers.mockResolvedValue([ + { id: '1', title: 'User 1' }, + { id: '2', title: 'User 2' }, + ]); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const result = await provider.handleCallback({ pinId: '123' }); + + expect(result.success).toBe(true); + expect(result.requiresProfileSelection).toBe(true); + expect(result.profiles?.length).toBe(2); + }); + + it('denies login when server access check fails', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ + id: 1, + username: 'user', + authToken: 'token', + }); + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + libraryId: 'lib', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(false); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const result = await provider.handleCallback({ pinId: '123' }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/do not have access/i); + }); + + it('creates user and returns tokens when auth succeeds', async () => { + plexServiceMock.checkPin.mockResolvedValue('token'); + plexServiceMock.getUserInfo.mockResolvedValue({ + id: 1, + username: 'user', + email: 'user@example.com', + thumb: 'avatar', + authToken: 'token', + }); + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + authToken: 'token', + libraryId: 'lib', + machineIdentifier: 'machine', + }); + plexServiceMock.verifyServerAccess.mockResolvedValue(true); + plexServiceMock.getHomeUsers.mockResolvedValue([]); + prismaMock.user.count.mockResolvedValue(1); + prismaMock.user.upsert.mockResolvedValue({ + id: 'user-1', + plexUsername: 'user', + plexEmail: 'user@example.com', + avatarUrl: 'avatar', + role: 'user', + }); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const result = await provider.handleCallback({ pinId: '123' }); + + expect(result.success).toBe(true); + expect(result.tokens?.accessToken).toBeTruthy(); + expect(result.user?.authProvider).toBe('plex'); + }); + + it('returns false when access validation has no server config', async () => { + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: null, + machineIdentifier: null, + }); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' }); + + expect(ok).toBe(false); + }); + + it('returns false when Plex auth token is missing in the database', async () => { + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + machineIdentifier: 'machine', + }); + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', authToken: null }); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' }); + + expect(ok).toBe(false); + }); + + it('decrypts tokens and verifies server access', async () => { + configMock.getPlexConfig.mockResolvedValue({ + serverUrl: 'http://plex', + machineIdentifier: 'machine', + }); + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', authToken: 'enc:token' }); + plexServiceMock.verifyServerAccess.mockResolvedValue(true); + + const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider'); + const provider = new PlexAuthProvider(); + const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' }); + + expect(ok).toBe(true); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:token'); + }); +}); + + diff --git a/tests/services/config.service.test.ts b/tests/services/config.service.test.ts new file mode 100644 index 0000000..a99f976 --- /dev/null +++ b/tests/services/config.service.test.ts @@ -0,0 +1,218 @@ +/** + * Component: Configuration Service Tests + * Documentation: documentation/backend/services/config.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import { DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible'; + +const prismaMock = createPrismaMock(); + +const encryptionMock = vi.hoisted(() => ({ + encrypt: vi.fn((value: string) => `enc:${value}`), + decrypt: vi.fn((value: string) => value.replace('enc:', '')), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/services/encryption.service', () => ({ + getEncryptionService: () => encryptionMock, +})); + +describe('ConfigurationService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('decrypts encrypted values on get', async () => { + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'plex.auth_token', + value: 'enc:secret', + encrypted: true, + }); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + const value = await service.get('plex.auth_token'); + + expect(value).toBe('secret'); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:secret'); + }); + + it('caches values for subsequent get calls', async () => { + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'system.log_level', + value: 'info', + encrypted: false, + }); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + + const first = await service.get('system.log_level'); + const second = await service.get('system.log_level'); + + expect(first).toBe('info'); + expect(second).toBe('info'); + expect(prismaMock.configuration.findUnique).toHaveBeenCalledTimes(1); + }); + + it('encrypts values when setting encrypted config', async () => { + prismaMock.configuration.upsert.mockResolvedValue({}); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + + await service.setMany([ + { key: 'plex.auth_token', value: 'secret', encrypted: true }, + ]); + + expect(encryptionMock.encrypt).toHaveBeenCalledWith('secret'); + expect(prismaMock.configuration.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + value: 'enc:secret', + encrypted: true, + }), + }) + ); + }); + + it('returns default Audible region when not configured', async () => { + prismaMock.configuration.findUnique.mockResolvedValue(null); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + const region = await service.getAudibleRegion(); + + expect(region).toBe(DEFAULT_AUDIBLE_REGION); + }); + + it('returns decrypted values for a category', async () => { + prismaMock.configuration.findMany.mockResolvedValue([ + { + key: 'plex.token', + value: 'enc:secret', + encrypted: true, + description: 'Plex token', + }, + ]); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + const category = await service.getCategory('plex'); + + expect(category['plex.token'].value).toBe('secret'); + expect(category['plex.token'].encrypted).toBe(true); + }); + + it('masks encrypted values when listing all config', async () => { + prismaMock.configuration.findMany.mockResolvedValue([ + { + key: 'plex.token', + value: 'secret', + encrypted: true, + category: 'plex', + description: 'Plex token', + }, + ]); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + const all = await service.getAll(); + + expect(all['plex.token'].value).toBe('***ENCRYPTED***'); + expect(all['plex.token'].category).toBe('plex'); + }); + + it('defaults backend mode to plex when unset', async () => { + prismaMock.configuration.findUnique.mockResolvedValue(null); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + const mode = await service.getBackendMode(); + + expect(mode).toBe('plex'); + }); + + it('returns true when audiobookshelf mode is enabled', async () => { + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'system.backend_mode', + value: 'audiobookshelf', + encrypted: false, + }); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + const enabled = await service.isAudiobookshelfMode(); + + expect(enabled).toBe(true); + }); + + it('builds Plex config from stored keys', async () => { + prismaMock.configuration.findUnique.mockImplementation(async ({ where: { key } }) => { + const values: Record = { + plex_url: 'http://plex', + plex_token: 'token', + plex_audiobook_library_id: 'lib-1', + plex_machine_identifier: 'machine', + }; + return values[key] + ? { key, value: values[key], encrypted: false } + : null; + }); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + const plexConfig = await service.getPlexConfig(); + + expect(plexConfig).toEqual({ + serverUrl: 'http://plex', + authToken: 'token', + libraryId: 'lib-1', + machineIdentifier: 'machine', + }); + }); + + it('clears cached entries when requested', async () => { + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'system.log_level', + value: 'info', + encrypted: false, + }); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + + const first = await service.get('system.log_level'); + prismaMock.configuration.findUnique.mockResolvedValue({ + key: 'system.log_level', + value: 'debug', + encrypted: false, + }); + + const cached = await service.get('system.log_level'); + service.clearCache('system.log_level'); + const updated = await service.get('system.log_level'); + + expect(first).toBe('info'); + expect(cached).toBe('info'); + expect(updated).toBe('debug'); + }); + + it('throws when setting configuration fails', async () => { + prismaMock.configuration.upsert.mockRejectedValue(new Error('db failed')); + + const { ConfigurationService } = await import('@/lib/services/config.service'); + const service = new ConfigurationService(); + + await expect( + service.setMany([{ key: 'system.test', value: '1' }]) + ).rejects.toThrow('db failed'); + }); +}); + + diff --git a/tests/services/ebook-scraper.test.ts b/tests/services/ebook-scraper.test.ts new file mode 100644 index 0000000..0807892 --- /dev/null +++ b/tests/services/ebook-scraper.test.ts @@ -0,0 +1,679 @@ +/** + * Component: E-book Sidecar Service Tests + * Documentation: documentation/integrations/ebook-sidecar.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'events'; +import path from 'path'; +import { clearMd5Cache, downloadEbook, testFlareSolverrConnection } from '@/lib/services/ebook-scraper'; + +const axiosMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +const AxiosErrorMock = vi.hoisted(() => + class MockAxiosError extends Error { + code?: string; + response?: { status?: number }; + config?: { url?: string }; + constructor(message?: string) { + super(message); + this.name = 'AxiosError'; + } + } +); + +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + unlink: vi.fn(), +})); + +const fsCoreMock = vi.hoisted(() => ({ + createWriteStream: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, + AxiosError: AxiosErrorMock, +})); + +vi.mock('fs/promises', () => ({ + default: fsMock, + ...fsMock, +})); +vi.mock('fs', () => fsCoreMock); + +describe('E-book sidecar', () => { + beforeEach(() => { + vi.clearAllMocks(); + fsMock.unlink.mockResolvedValue(undefined); + clearMd5Cache(); + vi.useRealTimers(); + }); + + it('tests FlareSolverr connections', async () => { + const longHtml = `${'Anna'.padEnd(1200, 'A')}`; + axiosMock.post.mockResolvedValue({ + data: { + status: 'ok', + solution: { status: 200, response: longHtml }, + }, + }); + + const result = await testFlareSolverrConnection('http://flare'); + + expect(result.success).toBe(true); + expect(result.responseTime).toBeTypeOf('number'); + }); + + it('returns false when FlareSolverr response is invalid', async () => { + axiosMock.post.mockResolvedValue({ + data: { + status: 'ok', + solution: { status: 200, response: 'nope' }, + }, + }); + + const result = await testFlareSolverrConnection('http://flare'); + + expect(result.success).toBe(false); + }); + + it('returns error details when FlareSolverr request fails', async () => { + axiosMock.post.mockRejectedValue(new Error('flare down')); + + const result = await testFlareSolverrConnection('http://flare'); + + expect(result.success).toBe(false); + expect(result.message).toContain('flare down'); + }); + + it('returns errors when FlareSolverr reports failure status', async () => { + axiosMock.post.mockResolvedValue({ + data: { + status: 'error', + message: 'bad', + }, + }); + + const result = await testFlareSolverrConnection('http://flare'); + + expect(result.success).toBe(false); + expect(result.message).toContain('FlareSolverr error'); + }); + + it('returns errors when FlareSolverr responds with HTTP errors', async () => { + axiosMock.post.mockResolvedValue({ + data: { + status: 'ok', + solution: { status: 403, response: '' }, + message: 'Forbidden', + }, + }); + + const result = await testFlareSolverrConnection('http://flare'); + + expect(result.success).toBe(false); + expect(result.message).toContain('FlareSolverr returned HTTP 403'); + }); + + it('downloads an ebook from ASIN search', async () => { + vi.useFakeTimers(); + + fsMock.access.mockRejectedValue(new Error('missing')); + fsMock.unlink.mockResolvedValue(undefined); + + const writer = new EventEmitter() as EventEmitter & { close: () => void }; + writer.close = vi.fn(); + fsCoreMock.createWriteStream.mockReturnValue(writer); + + axiosMock.get.mockImplementation(async (url: string, config?: any) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abc123')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/')) { + return { data: '
    https://files.example.com/book.epub
    ' }; + } + if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') { + return { + data: { + pipe: (dest: EventEmitter) => { + setTimeout(() => dest.emit('finish'), 0); + return dest; + }, + }, + }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN1', 'Title', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.format).toBe('epub'); + expect(result.filePath).toBe(path.join('/downloads', 'Title - Author.epub')); + + vi.useRealTimers(); + }); + + it('falls back to title search when ASIN search has no results', async () => { + vi.useFakeTimers(); + + fsMock.access.mockRejectedValue(new Error('missing')); + fsMock.unlink.mockResolvedValue(undefined); + + const writer = new EventEmitter() as EventEmitter & { close: () => void }; + writer.close = vi.fn(); + fsCoreMock.createWriteStream.mockReturnValue(writer); + + axiosMock.post.mockRejectedValue(new Error('flare down')); + axiosMock.get.mockImplementation(async (url: string, config?: any) => { + if (url.includes('/search?') && (url.includes('asin%3A') || url.includes('asin:'))) { + return { data: '' }; + } + if (url.includes('/search?') && url.includes('termtype_1=author')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abc123')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/')) { + return { data: '
    https://files.example.com/book.pdf
    ' }; + } + if (url === 'https://files.example.com/book.pdf' && config?.responseType === 'stream') { + return { + data: { + pipe: (dest: EventEmitter) => { + setTimeout(() => dest.emit('finish'), 0); + return dest; + }, + }, + }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN-NO', 'Title', 'Author', '/downloads', 'pdf', 'https://annas-archive.li', undefined, 'http://flare'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.format).toBe('pdf'); + expect(axiosMock.post).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('returns an error when no download links are available', async () => { + vi.useFakeTimers(); + + axiosMock.get.mockImplementation(async (url: string) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abcd12')) { + return { data: '
  • Slow
  • ' }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN3', 'Missing', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('No download links available'); + + vi.useRealTimers(); + }); + + it('returns success when file already exists', async () => { + vi.useFakeTimers(); + + fsMock.access.mockResolvedValue(undefined); + + axiosMock.get.mockImplementation(async (url: string) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abcdef')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/')) { + return { data: '
    https://files.example.com/book.epub
    ' }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN4', 'Existing', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.filePath).toBe(path.join('/downloads', 'Existing - Author.epub')); + + vi.useRealTimers(); + }); + + it('returns an error when downloads fail', async () => { + vi.useFakeTimers(); + + fsMock.access.mockRejectedValue(new Error('missing')); + fsMock.unlink.mockResolvedValue(undefined); + + const writer = new EventEmitter() as EventEmitter & { close: () => void }; + writer.close = vi.fn(); + fsCoreMock.createWriteStream.mockReturnValue(writer); + + axiosMock.get.mockImplementation(async (url: string, config?: any) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/deadbeef')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/')) { + return { data: '
    https://files.example.com/book.epub
    ' }; + } + if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') { + return { + data: { + pipe: (dest: EventEmitter) => { + setTimeout(() => dest.emit('error', new Error('download error')), 0); + return dest; + }, + }, + }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN5', 'Fail', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('download attempts failed'); + + vi.useRealTimers(); + }); + + it('uses cached ASIN search results on repeat calls', async () => { + vi.useFakeTimers(); + + fsMock.access.mockRejectedValue(new Error('missing')); + fsMock.unlink.mockResolvedValue(undefined); + + const writer = new EventEmitter() as EventEmitter & { close: () => void }; + writer.close = vi.fn(); + fsCoreMock.createWriteStream.mockReturnValue(writer); + + let searchCalls = 0; + axiosMock.get.mockImplementation(async (url: string, config?: any) => { + if (url.includes('/search?')) { + searchCalls += 1; + if (searchCalls > 1) { + throw new Error('Search called twice'); + } + return { data: 'Result' }; + } + if (url.includes('/md5/cafebabe')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/')) { + return { data: '
    https://files.example.com/book.epub
    ' }; + } + if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') { + return { + data: { + pipe: (dest: EventEmitter) => { + setTimeout(() => dest.emit('finish'), 0); + return dest; + }, + }, + }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const first = downloadEbook('ASIN6', 'Cached', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + await first; + + const second = downloadEbook('ASIN6', 'Cached', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await second; + + expect(result.success).toBe(true); + expect(searchCalls).toBe(1); + + vi.useRealTimers(); + }); + + it('returns an error when no results are found', async () => { + vi.useFakeTimers(); + + axiosMock.get.mockResolvedValue({ data: '' }); + + const promise = downloadEbook('ASIN2', 'Missing', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('No search results'); + + vi.useRealTimers(); + }); + + it('uses FlareSolverr when configured for HTML fetches', async () => { + vi.useFakeTimers(); + + axiosMock.post + .mockResolvedValueOnce({ + data: { + status: 'ok', + solution: { status: 200, response: 'Result' }, + }, + }) + .mockResolvedValueOnce({ + data: { + status: 'ok', + solution: { status: 200, response: 'No links' }, + }, + }); + + const promise = downloadEbook( + 'ASIN7', + 'Title', + 'Author', + '/downloads', + 'epub', + 'https://annas-archive.li', + undefined, + 'http://flare' + ); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('No download links'); + expect(axiosMock.get).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('filters ASIN search results and warns on challenge pages', async () => { + vi.useFakeTimers(); + + const searchHtml = ` +
    + Recent +
    +
    + Partial +
    + Valid + `; + const md5Html = 'challenge-running'; + + axiosMock.get.mockImplementation(async (url: string) => { + if (url.includes('/search?')) { + return { data: searchHtml }; + } + if (url.includes('/md5/abc333')) { + return { data: md5Html }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN8', 'Title', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('No download links'); + + vi.useRealTimers(); + }); + + it('returns empty slow links when md5 page fetch fails', async () => { + vi.useFakeTimers(); + + axiosMock.get.mockImplementation(async (url: string) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abc123')) { + throw new Error('md5 down'); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN9', 'Title', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('No download links'); + + vi.useRealTimers(); + }); + + it('returns errors when no download URL is found on slow pages', async () => { + vi.useFakeTimers(); + + axiosMock.get.mockImplementation(async (url: string) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abc123')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/abc123/0/1')) { + return { data: 'No url here' }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN10', 'Title', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('All 1 download attempts failed'); + + vi.useRealTimers(); + }); + + it('marks attempts failed when direct downloads fail', async () => { + vi.useFakeTimers(); + + fsMock.access.mockRejectedValue(new Error('missing')); + + axiosMock.get.mockImplementation(async (url: string, config?: any) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abc123')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/abc123/0/1')) { + return { data: '
    https://files.example.com/book.epub
    ' }; + } + if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') { + throw new Error('download failed'); + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN11', 'Title', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('All 1 download attempts failed'); + + vi.useRealTimers(); + }); + + it('returns errors when logger throws during download', async () => { + const logger = { + info: vi.fn(() => { + throw new Error('logger boom'); + }), + warn: vi.fn(), + error: vi.fn(), + }; + + const result = await downloadEbook('ASIN12', 'Title', 'Author', '/downloads', 'epub', undefined, logger as any); + + expect(result.success).toBe(false); + expect(result.error).toContain('logger boom'); + expect(logger.error).toHaveBeenCalled(); + }); + + it('returns null when ASIN and title searches fail', async () => { + vi.useFakeTimers(); + + const error = new AxiosErrorMock('network down'); + error.code = 'ENOTFOUND'; + + axiosMock.get.mockRejectedValue(error); + + const promise = downloadEbook('ASIN13', 'Title', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('No search results found'); + + vi.useRealTimers(); + }); + + it('uses cached MD5 values for title searches', async () => { + vi.useFakeTimers(); + + const searchHtml = ` +
    + Recent +
    +
    + Partial +
    + Valid + `; + + axiosMock.get.mockImplementation(async (url: string) => { + if (url.includes('/search?')) { + return { data: searchHtml }; + } + if (url.includes('/md5/cached')) { + return { data: '' }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const first = downloadEbook('', 'Cached', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + await first; + + const second = downloadEbook('', 'Cached', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await second; + + const searchCalls = axiosMock.get.mock.calls.filter(([url]) => String(url).includes('/search?')); + expect(searchCalls).toHaveLength(1); + expect(result.success).toBe(false); + + vi.useRealTimers(); + }); + + it('downloads files when format is any and URL is in body text', async () => { + vi.useFakeTimers(); + + fsMock.access.mockRejectedValue(new Error('missing')); + + const writer = new EventEmitter() as EventEmitter & { close: () => void }; + writer.close = vi.fn(); + fsCoreMock.createWriteStream.mockReturnValue(writer); + + axiosMock.get.mockImplementation(async (url: string, config?: any) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/deadbeef')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/deadbeef/0/1')) { + return { data: 'https://files.example.com/book.pdf' }; + } + if (url === 'https://files.example.com/book.pdf' && config?.responseType === 'stream') { + return { + data: { + pipe: (dest: EventEmitter) => { + setTimeout(() => dest.emit('finish'), 0); + return dest; + }, + }, + }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN14', 'Any', 'Author', '/downloads', 'any'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.format).toBe('pdf'); + + vi.useRealTimers(); + }); + + it('times out downloads that never finish', async () => { + vi.useFakeTimers(); + + fsMock.access.mockRejectedValue(new Error('missing')); + + const writer = new EventEmitter() as EventEmitter & { close: () => void }; + writer.close = vi.fn(); + fsCoreMock.createWriteStream.mockReturnValue(writer); + + axiosMock.get.mockImplementation(async (url: string, config?: any) => { + if (url.includes('/search?')) { + return { data: 'Result' }; + } + if (url.includes('/md5/abc999')) { + return { data: '
  • Slow (no waitlist)
  • ' }; + } + if (url.includes('/slow_download/abc999/0/1')) { + return { data: '
    https://files.example.com/book.epub
    ' }; + } + if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') { + return { + data: { + pipe: (dest: EventEmitter) => dest, + }, + }; + } + throw new Error(`Unexpected URL: ${url}`); + }); + + const promise = downloadEbook('ASIN15', 'Title', 'Author', '/downloads'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.success).toBe(false); + expect(result.error).toContain('All 1 download attempts failed'); + + vi.useRealTimers(); + }); +}); diff --git a/tests/services/encryption.service.test.ts b/tests/services/encryption.service.test.ts new file mode 100644 index 0000000..33a4474 --- /dev/null +++ b/tests/services/encryption.service.test.ts @@ -0,0 +1,54 @@ +/** + * Component: Encryption Service Tests + * Documentation: documentation/backend/services/config.md + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const ORIGINAL_KEY = process.env.CONFIG_ENCRYPTION_KEY; + +afterEach(() => { + process.env.CONFIG_ENCRYPTION_KEY = ORIGINAL_KEY; + vi.resetModules(); +}); + +describe('EncryptionService', () => { + it('throws when encryption key is missing', async () => { + delete process.env.CONFIG_ENCRYPTION_KEY; + vi.resetModules(); + + const { EncryptionService } = await import('@/lib/services/encryption.service'); + expect(() => new EncryptionService()).toThrow(/CONFIG_ENCRYPTION_KEY/); + }); + + it('encrypts and decrypts values', async () => { + process.env.CONFIG_ENCRYPTION_KEY = 'a'.repeat(32); + vi.resetModules(); + + const { EncryptionService } = await import('@/lib/services/encryption.service'); + const service = new EncryptionService(); + + const encrypted = service.encrypt('secret'); + const decrypted = service.decrypt(encrypted); + + expect(decrypted).toBe('secret'); + }); + + it('rejects invalid encrypted data formats', async () => { + process.env.CONFIG_ENCRYPTION_KEY = 'b'.repeat(32); + vi.resetModules(); + + const { EncryptionService } = await import('@/lib/services/encryption.service'); + const service = new EncryptionService(); + + expect(() => service.decrypt('invalid')).toThrow(/Decryption failed/); + }); + + it('generates a random key', async () => { + const { EncryptionService } = await import('@/lib/services/encryption.service'); + const key = EncryptionService.generateKey(); + + expect(typeof key).toBe('string'); + expect(key.length).toBeGreaterThan(40); + }); +}); diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts new file mode 100644 index 0000000..b5b3600 --- /dev/null +++ b/tests/services/job-queue.service.test.ts @@ -0,0 +1,624 @@ +/** + * Component: Job Queue Service Tests + * Documentation: documentation/backend/services/jobs.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +const processorsMock = vi.hoisted(() => ({ + processSearchIndexers: vi.fn().mockResolvedValue('ok'), + processDownloadTorrent: vi.fn().mockResolvedValue('ok'), + processMonitorDownload: vi.fn().mockResolvedValue('ok'), + processOrganizeFiles: vi.fn().mockResolvedValue('ok'), + processScanPlex: vi.fn().mockResolvedValue('ok'), + processMatchPlex: vi.fn().mockResolvedValue('ok'), + processPlexRecentlyAddedCheck: vi.fn().mockResolvedValue('ok'), + processMonitorRssFeeds: vi.fn().mockResolvedValue('ok'), + processAudibleRefresh: vi.fn().mockResolvedValue('ok'), + processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'), + processRetryFailedImports: vi.fn().mockResolvedValue('ok'), + processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), +})); + +const queueMock = vi.hoisted(() => ({ + on: vi.fn(), + process: vi.fn(), + add: vi.fn(), + getJobCounts: vi.fn(), + getActive: vi.fn(), + getJob: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + close: vi.fn(), + removeRepeatable: vi.fn(), + getRepeatableJobs: vi.fn(), + setMaxListeners: vi.fn(), +})); + +const redisMock = vi.hoisted(() => ({ + setMaxListeners: vi.fn(), + disconnect: vi.fn(), +})); + +const QueueConstructor = vi.hoisted(() => + vi.fn(function Queue() { + return queueMock; + }) +); + +const RedisConstructor = vi.hoisted(() => + vi.fn(function Redis() { + return redisMock; + }) +); + +vi.mock('bull', () => ({ + default: QueueConstructor, +})); + +vi.mock('ioredis', () => ({ + default: RedisConstructor, +})); + +vi.mock('@/lib/processors/search-indexers.processor', () => ({ + processSearchIndexers: processorsMock.processSearchIndexers, +})); + +vi.mock('@/lib/processors/download-torrent.processor', () => ({ + processDownloadTorrent: processorsMock.processDownloadTorrent, +})); + +vi.mock('@/lib/processors/monitor-download.processor', () => ({ + processMonitorDownload: processorsMock.processMonitorDownload, +})); + +vi.mock('@/lib/processors/organize-files.processor', () => ({ + processOrganizeFiles: processorsMock.processOrganizeFiles, +})); + +vi.mock('@/lib/processors/scan-plex.processor', () => ({ + processScanPlex: processorsMock.processScanPlex, +})); + +vi.mock('@/lib/processors/match-plex.processor', () => ({ + processMatchPlex: processorsMock.processMatchPlex, +})); + +vi.mock('@/lib/processors/plex-recently-added.processor', () => ({ + processPlexRecentlyAddedCheck: processorsMock.processPlexRecentlyAddedCheck, +})); + +vi.mock('@/lib/processors/monitor-rss-feeds.processor', () => ({ + processMonitorRssFeeds: processorsMock.processMonitorRssFeeds, +})); + +vi.mock('@/lib/processors/audible-refresh.processor', () => ({ + processAudibleRefresh: processorsMock.processAudibleRefresh, +})); + +vi.mock('@/lib/processors/retry-missing-torrents.processor', () => ({ + processRetryMissingTorrents: processorsMock.processRetryMissingTorrents, +})); + +vi.mock('@/lib/processors/retry-failed-imports.processor', () => ({ + processRetryFailedImports: processorsMock.processRetryFailedImports, +})); + +vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({ + processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +describe('JobQueueService', () => { + beforeEach(() => { + vi.clearAllMocks(); + queueMock.add.mockReset(); + queueMock.getJobCounts.mockReset(); + queueMock.getJob.mockReset(); + queueMock.getActive.mockReset(); + queueMock.process.mockReset(); + queueMock.on.mockReset(); + queueMock.getRepeatableJobs.mockReset(); + prismaMock.job.create.mockReset(); + prismaMock.job.update.mockReset(); + prismaMock.job.updateMany.mockReset(); + prismaMock.job.findUnique.mockReset(); + prismaMock.job.findFirst.mockReset(); + prismaMock.job.findMany.mockReset(); + prismaMock.scheduledJob.update.mockReset(); + prismaMock.request.update.mockReset(); + prismaMock.downloadHistory.update.mockReset(); + }); + + it('adds search jobs with priority and stores Bull job ID', async () => { + prismaMock.job.create.mockResolvedValue({ id: 'job-1' }); + queueMock.add.mockResolvedValue({ id: 'bull-1' }); + prismaMock.job.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const jobId = await service.addSearchJob('req-1', { + id: 'ab-1', + title: 'Title', + author: 'Author', + asin: 'ASIN1', + }); + + expect(jobId).toBe('job-1'); + expect(prismaMock.job.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + requestId: 'req-1', + type: 'search_indexers', + priority: 10, + }), + }) + ); + expect(queueMock.add).toHaveBeenCalledWith( + 'search_indexers', + expect.objectContaining({ jobId: 'job-1', requestId: 'req-1' }), + expect.objectContaining({ priority: 10 }) + ); + expect(prismaMock.job.update).toHaveBeenCalledWith({ + where: { id: 'job-1' }, + data: { bullJobId: 'bull-1' }, + }); + }); + + it('adds download jobs with expected priority', async () => { + prismaMock.job.create.mockResolvedValue({ id: 'job-2' }); + queueMock.add.mockResolvedValue({ id: 'bull-2' }); + prismaMock.job.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await service.addDownloadJob('req-1', { id: 'ab-1', title: 'Title', author: 'Author' }, { hash: 'hash' } as any); + + expect(queueMock.add).toHaveBeenCalledWith( + 'download_torrent', + expect.objectContaining({ requestId: 'req-1', jobId: 'job-2' }), + expect.objectContaining({ priority: 9 }) + ); + }); + + it('adds monitor jobs with delay in milliseconds', async () => { + prismaMock.job.create.mockResolvedValue({ id: 'job-3' }); + queueMock.add.mockResolvedValue({ id: 'bull-3' }); + prismaMock.job.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await service.addMonitorJob('req-2', 'hist-1', 'client-1', 'qbittorrent', 15); + + expect(queueMock.add).toHaveBeenCalledWith( + 'monitor_download', + expect.objectContaining({ requestId: 'req-2', jobId: 'job-3' }), + expect.objectContaining({ priority: 5, delay: 15000 }) + ); + }); + + it('adds organize jobs with target path payload', async () => { + prismaMock.job.create.mockResolvedValue({ id: 'job-4' }); + queueMock.add.mockResolvedValue({ id: 'bull-4' }); + prismaMock.job.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await service.addOrganizeJob('req-3', 'ab-3', '/downloads/book', '/media/book'); + + expect(queueMock.add).toHaveBeenCalledWith( + 'organize_files', + expect.objectContaining({ requestId: 'req-3', targetPath: '/media/book', jobId: 'job-4' }), + expect.objectContaining({ priority: 8 }) + ); + }); + + it('adds plex and scheduled jobs with expected priorities', async () => { + const jobIds = ['job-5', 'job-6', 'job-7', 'job-8', 'job-9', 'job-10', 'job-11', 'job-12']; + jobIds.forEach((id) => prismaMock.job.create.mockResolvedValueOnce({ id })); + queueMock.add.mockResolvedValue({ id: 'bull' }); + prismaMock.job.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + + await service.addPlexScanJob('lib-1', true, '/path'); + await service.addPlexMatchJob('req-1', 'ab-1', 'Title', 'Author'); + await service.addPlexRecentlyAddedJob('sched-1'); + await service.addMonitorRssFeedsJob('sched-2'); + await service.addAudibleRefreshJob('sched-3'); + await service.addRetryMissingTorrentsJob('sched-4'); + await service.addRetryFailedImportsJob('sched-5'); + await service.addCleanupSeededTorrentsJob('sched-6'); + + expect(queueMock.add.mock.calls[0][0]).toBe('scan_plex'); + expect(queueMock.add.mock.calls[0][2].priority).toBe(7); + expect(queueMock.add.mock.calls[0][1]).toEqual(expect.objectContaining({ libraryId: 'lib-1', partial: true, path: '/path' })); + + expect(queueMock.add.mock.calls[1][0]).toBe('match_plex'); + expect(queueMock.add.mock.calls[1][2].priority).toBe(6); + + expect(queueMock.add.mock.calls[2][0]).toBe('plex_recently_added_check'); + expect(queueMock.add.mock.calls[2][2].priority).toBe(8); + + expect(queueMock.add.mock.calls[3][0]).toBe('monitor_rss_feeds'); + expect(queueMock.add.mock.calls[3][2].priority).toBe(8); + + expect(queueMock.add.mock.calls[4][0]).toBe('audible_refresh'); + expect(queueMock.add.mock.calls[4][2].priority).toBe(9); + + expect(queueMock.add.mock.calls[5][0]).toBe('retry_missing_torrents'); + expect(queueMock.add.mock.calls[5][2].priority).toBe(7); + + expect(queueMock.add.mock.calls[6][0]).toBe('retry_failed_imports'); + expect(queueMock.add.mock.calls[6][2].priority).toBe(7); + + expect(queueMock.add.mock.calls[7][0]).toBe('cleanup_seeded_torrents'); + expect(queueMock.add.mock.calls[7][2].priority).toBe(10); + }); + + it('returns queue stats with safe defaults', async () => { + queueMock.getJobCounts.mockResolvedValue({ waiting: 2, active: 1 }); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const stats = await service.getQueueStats(); + + expect(stats).toEqual({ + waiting: 2, + active: 1, + completed: 0, + failed: 0, + delayed: 0, + }); + }); + + it('returns a single job by ID', async () => { + prismaMock.job.findUnique.mockResolvedValue({ id: 'job-10' }); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const job = await service.getJob('job-10'); + + expect(prismaMock.job.findUnique).toHaveBeenCalledWith({ where: { id: 'job-10' } }); + expect(job).toEqual({ id: 'job-10' }); + }); + + it('returns jobs for a request ordered by createdAt', async () => { + prismaMock.job.findMany.mockResolvedValue([{ id: 'job-11' }]); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const jobs = await service.getJobsByRequest('req-10'); + + expect(prismaMock.job.findMany).toHaveBeenCalledWith({ + where: { requestId: 'req-10' }, + orderBy: { createdAt: 'desc' }, + }); + expect(jobs).toEqual([{ id: 'job-11' }]); + }); + + it('retries a failed job and resets metadata', async () => { + prismaMock.job.findUnique.mockResolvedValue({ id: 'job-1', bullJobId: 'bull-1' }); + queueMock.getJob.mockResolvedValue({ retry: vi.fn() }); + prismaMock.job.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await service.retryJob('job-1'); + + expect(queueMock.getJob).toHaveBeenCalledWith('bull-1'); + expect(prismaMock.job.update).toHaveBeenCalledWith({ + where: { id: 'job-1' }, + data: { + status: 'pending', + attempts: 0, + errorMessage: null, + stackTrace: null, + }, + }); + }); + + it('throws when retrying an unknown job', async () => { + prismaMock.job.findUnique.mockResolvedValue(null); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + + await expect(service.retryJob('missing')).rejects.toThrow('Job not found'); + }); + + it('cancels jobs and removes Bull entry', async () => { + prismaMock.job.findUnique.mockResolvedValue({ id: 'job-2', bullJobId: 'bull-2' }); + queueMock.getJob.mockResolvedValue({ remove: vi.fn() }); + prismaMock.job.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await service.cancelJob('job-2'); + + expect(queueMock.getJob).toHaveBeenCalledWith('bull-2'); + expect(prismaMock.job.update).toHaveBeenCalledWith({ + where: { id: 'job-2' }, + data: { status: 'cancelled' }, + }); + }); + + it('adds and removes repeatable jobs', async () => { + queueMock.add.mockResolvedValue({}); + queueMock.removeRepeatable.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await service.addRepeatableJob('audible_refresh', { scheduledJobId: 'sched-1' }, '0 0 * * *', 'scheduled-1'); + await service.removeRepeatableJob('audible_refresh', '0 0 * * *', 'scheduled-1'); + + expect(queueMock.add).toHaveBeenCalledWith( + 'audible_refresh', + { scheduledJobId: 'sched-1' }, + { repeat: { cron: '0 0 * * *' }, jobId: 'scheduled-1' } + ); + expect(queueMock.removeRepeatable).toHaveBeenCalledWith('audible_refresh', { + cron: '0 0 * * *', + jobId: 'scheduled-1', + }); + }); + + it('creates job records for timer-triggered jobs', async () => { + prismaMock.job.findFirst.mockResolvedValue(null); + prismaMock.job.create.mockResolvedValue({ id: 'job-3' }); + prismaMock.scheduledJob.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const payload = await (service as any).ensureJobRecord( + { id: 'bull-3', data: { scheduledJobId: 'sched-3' } }, + 'audible_refresh' + ); + + expect(prismaMock.job.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + bullJobId: 'bull-3', + type: 'audible_refresh', + }), + }) + ); + expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({ + where: { id: 'sched-3' }, + data: { lastRun: expect.any(Date) }, + }); + expect(payload.jobId).toBe('job-3'); + }); + + it('returns existing job IDs for scheduled jobs already in the database', async () => { + prismaMock.job.findFirst.mockResolvedValue({ id: 'job-4' }); + prismaMock.scheduledJob.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const payload = await (service as any).ensureJobRecord( + { id: 'bull-4', data: { scheduledJobId: 'sched-4' } }, + 'cleanup_seeded_torrents' + ); + + expect(payload.jobId).toBe('job-4'); + expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({ + where: { id: 'sched-4' }, + data: { lastRun: expect.any(Date) }, + }); + }); + + it('returns payload unchanged when jobId already exists', async () => { + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const payload = await (service as any).ensureJobRecord( + { id: 'bull-5', data: { jobId: 'job-5' } }, + 'audible_refresh' + ); + + expect(payload.jobId).toBe('job-5'); + expect(prismaMock.job.findFirst).not.toHaveBeenCalled(); + }); + + it('updates job metadata on lifecycle events', async () => { + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const updateSpy = vi.spyOn(service as any, 'updateJobInDatabase').mockResolvedValue(undefined); + + const handlers = Object.fromEntries(queueMock.on.mock.calls.map(([event, handler]) => [event, handler])); + + await handlers.active({ id: 'bull-10' }); + await handlers.completed({ id: 'bull-10' }, { ok: true }); + await handlers.stalled({ id: 'bull-10' }); + + expect(updateSpy).toHaveBeenCalledWith('bull-10', 'active'); + expect(updateSpy).toHaveBeenCalledWith('bull-10', 'completed', { ok: true }); + expect(updateSpy).toHaveBeenCalledWith('bull-10', 'stuck'); + }); + + it('marks monitor download failures and updates request status', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.downloadHistory.update.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + new JobQueueService(); + + const handlers = Object.fromEntries(queueMock.on.mock.calls.map(([event, handler]) => [event, handler])); + await handlers.failed( + { + id: 'bull-11', + name: 'monitor_download', + data: { requestId: 'req-1', downloadHistoryId: 'hist-1' }, + attemptsMade: 3, + }, + new Error('Monitor failed') + ); + + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'req-1' }, + data: expect.objectContaining({ status: 'failed' }), + }) + ); + expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'hist-1' }, + data: expect.objectContaining({ downloadStatus: 'failed' }), + }) + ); + }); + + it('updates database fields for completed jobs', async () => { + prismaMock.job.updateMany.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await (service as any).updateJobInDatabase('bull-12', 'completed', { result: true }, 'err', 'stack'); + + expect(prismaMock.job.updateMany).toHaveBeenCalledWith({ + where: { bullJobId: 'bull-12' }, + data: expect.objectContaining({ + status: 'completed', + result: { result: true }, + errorMessage: 'err', + stackTrace: 'stack', + }), + }); + }); + + it('sets startedAt when jobs become active', async () => { + prismaMock.job.updateMany.mockResolvedValue({}); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + await (service as any).updateJobInDatabase('bull-13', 'active'); + + expect(prismaMock.job.updateMany).toHaveBeenCalledWith({ + where: { bullJobId: 'bull-13' }, + data: expect.objectContaining({ + status: 'active', + startedAt: expect.any(Date), + }), + }); + }); + + it('swallows database errors when updating job status', async () => { + prismaMock.job.updateMany.mockRejectedValue(new Error('db down')); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + + await expect((service as any).updateJobInDatabase('bull-14', 'completed')).resolves.toBeUndefined(); + }); + + it('registers processors for supported job types', async () => { + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + new JobQueueService(); + + const jobTypes = queueMock.process.mock.calls.map(([type]) => type); + expect(jobTypes).toContain('search_indexers'); + expect(jobTypes).toContain('download_torrent'); + expect(jobTypes).toContain('monitor_download'); + expect(jobTypes).toContain('audible_refresh'); + }); + + it('invokes processor handlers for registered jobs', async () => { + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + new JobQueueService(); + + const handlers = queueMock.process.mock.calls.map((call) => call[2] || call[1]); + for (const handler of handlers) { + await handler({ id: 'bull-processor', data: { jobId: 'job-processor', scheduledJobId: 'sched-1' } }); + } + + expect(processorsMock.processSearchIndexers).toHaveBeenCalled(); + expect(processorsMock.processDownloadTorrent).toHaveBeenCalled(); + expect(processorsMock.processMonitorDownload).toHaveBeenCalled(); + expect(processorsMock.processOrganizeFiles).toHaveBeenCalled(); + expect(processorsMock.processScanPlex).toHaveBeenCalled(); + expect(processorsMock.processMatchPlex).toHaveBeenCalled(); + expect(processorsMock.processPlexRecentlyAddedCheck).toHaveBeenCalled(); + expect(processorsMock.processMonitorRssFeeds).toHaveBeenCalled(); + expect(processorsMock.processAudibleRefresh).toHaveBeenCalled(); + expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled(); + expect(processorsMock.processRetryFailedImports).toHaveBeenCalled(); + expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled(); + }); + + it('returns repeatable jobs from the queue', async () => { + queueMock.getRepeatableJobs.mockResolvedValue([{ key: 'job-1' }]); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const jobs = await service.getRepeatableJobs(); + + expect(queueMock.getRepeatableJobs).toHaveBeenCalled(); + expect(jobs).toEqual([{ key: 'job-1' }]); + }); + + it('returns active jobs from prisma using Bull job IDs', async () => { + queueMock.getActive.mockResolvedValue([{ id: 'bull-20' }, { id: 'bull-21' }]); + prismaMock.job.findMany.mockResolvedValue([{ id: 'job-20' }]); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const jobs = await service.getActiveJobs(); + + expect(prismaMock.job.findMany).toHaveBeenCalledWith({ + where: { bullJobId: { in: ['bull-20', 'bull-21'] } }, + }); + expect(jobs).toEqual([{ id: 'job-20' }]); + }); + + it('returns failed jobs with limit', async () => { + prismaMock.job.findMany.mockResolvedValue([{ id: 'job-30' }]); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + const jobs = await service.getFailedJobs(10); + + expect(prismaMock.job.findMany).toHaveBeenCalledWith({ + where: { status: 'failed' }, + orderBy: { updatedAt: 'desc' }, + take: 10, + }); + expect(jobs).toEqual([{ id: 'job-30' }]); + }); + + it('throws when cancelling unknown jobs', async () => { + prismaMock.job.findUnique.mockResolvedValue(null); + + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + + await expect(service.cancelJob('missing')).rejects.toThrow('Job not found'); + }); + + it('pauses and resumes the queue', async () => { + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + + await service.pauseQueue(); + await service.resumeQueue(); + + expect(queueMock.pause).toHaveBeenCalled(); + expect(queueMock.resume).toHaveBeenCalled(); + }); + + it('closes the queue and disconnects redis', async () => { + const { JobQueueService } = await import('@/lib/services/job-queue.service'); + const service = new JobQueueService(); + + await service.close(); + + expect(queueMock.close).toHaveBeenCalled(); + expect(redisMock.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/tests/services/library/audiobookshelf-library.service.test.ts b/tests/services/library/audiobookshelf-library.service.test.ts new file mode 100644 index 0000000..6359e95 --- /dev/null +++ b/tests/services/library/audiobookshelf-library.service.test.ts @@ -0,0 +1,150 @@ +/** + * Component: Audiobookshelf Library Service Tests + * Documentation: documentation/features/audiobookshelf-integration.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AudiobookshelfLibraryService } from '@/lib/services/library/AudiobookshelfLibraryService'; + +const apiMock = vi.hoisted(() => ({ + getABSServerInfo: vi.fn(), + getABSLibraries: vi.fn(), + getABSLibraryItems: vi.fn(), + getABSRecentItems: vi.fn(), + getABSItem: vi.fn(), + searchABSItems: vi.fn(), + triggerABSScan: vi.fn(), +})); + +vi.mock('@/lib/services/audiobookshelf/api', () => apiMock); + +describe('AudiobookshelfLibraryService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('tests connection and returns server info', async () => { + apiMock.getABSServerInfo.mockResolvedValue({ name: 'ABS', version: '2.0.0' }); + + const service = new AudiobookshelfLibraryService(); + const result = await service.testConnection(); + + expect(result.success).toBe(true); + expect(result.serverInfo).toEqual({ + name: 'ABS', + version: '2.0.0', + identifier: 'ABS', + }); + }); + + it('returns errors when server info fails', async () => { + apiMock.getABSServerInfo.mockRejectedValue(new Error('No connection')); + + const service = new AudiobookshelfLibraryService(); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toBe('No connection'); + }); + + it('filters audiobook libraries only', async () => { + apiMock.getABSLibraries.mockResolvedValue([ + { id: 'lib-1', name: 'Books', mediaType: 'book', stats: { totalItems: 10 } }, + { id: 'lib-2', name: 'Podcasts', mediaType: 'podcast', stats: { totalItems: 5 } }, + ]); + + const service = new AudiobookshelfLibraryService(); + const libs = await service.getLibraries(); + + expect(libs).toEqual([ + { id: 'lib-1', name: 'Books', type: 'book', itemCount: 10 }, + ]); + }); + + it('maps library items to generic fields', async () => { + apiMock.getABSLibraryItems.mockResolvedValue([ + { + id: 'item-1', + addedAt: 1700000000000, + updatedAt: 1700000100000, + media: { + duration: 3600, + coverPath: '/covers/1.jpg', + metadata: { + title: 'Title', + authorName: 'Author', + narratorName: 'Narrator', + description: 'Desc', + asin: 'ASIN1', + isbn: 'ISBN1', + publishedYear: '2020', + }, + }, + }, + ]); + + const service = new AudiobookshelfLibraryService(); + const items = await service.getLibraryItems('lib-1'); + + expect(items[0]).toEqual({ + id: 'item-1', + externalId: 'item-1', + title: 'Title', + author: 'Author', + narrator: 'Narrator', + description: 'Desc', + coverUrl: '/api/items/item-1/cover', + duration: 3600, + asin: 'ASIN1', + isbn: 'ISBN1', + year: 2020, + addedAt: new Date(1700000000000), + updatedAt: new Date(1700000100000), + }); + }); + + it('returns null when item fetch fails', async () => { + apiMock.getABSItem.mockRejectedValue(new Error('missing')); + + const service = new AudiobookshelfLibraryService(); + const result = await service.getItem('item-1'); + + expect(result).toBeNull(); + }); + + it('searches items and maps results', async () => { + apiMock.searchABSItems.mockResolvedValue([ + { + libraryItem: { + id: 'item-2', + addedAt: 1700000000000, + updatedAt: 1700000000000, + media: { + duration: 200, + metadata: { + title: 'Search Title', + authorName: 'Search Author', + narratorName: '', + description: '', + }, + }, + }, + }, + ]); + + const service = new AudiobookshelfLibraryService(); + const items = await service.searchItems('lib-1', 'Search'); + + expect(items[0].title).toBe('Search Title'); + expect(items[0].author).toBe('Search Author'); + }); + + it('triggers library scans', async () => { + apiMock.triggerABSScan.mockResolvedValue(undefined); + + const service = new AudiobookshelfLibraryService(); + await service.triggerLibraryScan('lib-1'); + + expect(apiMock.triggerABSScan).toHaveBeenCalledWith('lib-1'); + }); +}); diff --git a/tests/services/library/library-factory.test.ts b/tests/services/library/library-factory.test.ts new file mode 100644 index 0000000..94a3505 --- /dev/null +++ b/tests/services/library/library-factory.test.ts @@ -0,0 +1,62 @@ +/** + * Component: Library Service Factory Tests + * Documentation: documentation/features/audiobookshelf-integration.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearLibraryServiceCache, getLibraryService } from '@/lib/services/library'; + +const MockPlexService = vi.hoisted(() => class MockPlexService {}); +const MockAbsService = vi.hoisted(() => class MockAbsService {}); + +const configServiceMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/services/library/PlexLibraryService', () => ({ + PlexLibraryService: MockPlexService, +})); + +vi.mock('@/lib/services/library/AudiobookshelfLibraryService', () => ({ + AudiobookshelfLibraryService: MockAbsService, +})); + +describe('Library service factory', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearLibraryServiceCache(); + }); + + it('returns Plex service when backend mode is plex', async () => { + configServiceMock.getBackendMode.mockResolvedValue('plex'); + + const service = await getLibraryService(); + + expect(service).toBeInstanceOf(MockPlexService); + }); + + it('returns cached service when mode is unchanged', async () => { + configServiceMock.getBackendMode.mockResolvedValue('plex'); + + const first = await getLibraryService(); + const second = await getLibraryService(); + + expect(first).toBe(second); + }); + + it('switches to Audiobookshelf service when mode changes', async () => { + configServiceMock.getBackendMode + .mockResolvedValueOnce('plex') + .mockResolvedValueOnce('audiobookshelf'); + + const first = await getLibraryService(); + const second = await getLibraryService(); + + expect(first).toBeInstanceOf(MockPlexService); + expect(second).toBeInstanceOf(MockAbsService); + }); +}); diff --git a/tests/services/library/plex-library.service.test.ts b/tests/services/library/plex-library.service.test.ts new file mode 100644 index 0000000..0b25bfe --- /dev/null +++ b/tests/services/library/plex-library.service.test.ts @@ -0,0 +1,214 @@ +/** + * Component: Plex Library Service Tests + * Documentation: documentation/features/audiobookshelf-integration.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlexLibraryService } from '@/lib/services/library/PlexLibraryService'; + +const plexServiceMock = vi.hoisted(() => ({ + testConnection: vi.fn(), + getLibraries: vi.fn(), + getLibraryContent: vi.fn(), + getRecentlyAdded: vi.fn(), + getItemMetadata: vi.fn(), + searchLibrary: vi.fn(), + scanLibrary: vi.fn(), +})); + +const configServiceMock = vi.hoisted(() => ({ + getPlexConfig: vi.fn(), +})); + +vi.mock('@/lib/integrations/plex.service', () => ({ + getPlexService: () => plexServiceMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +describe('PlexLibraryService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns error when Plex config is incomplete', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null }); + + const service = new PlexLibraryService(); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Plex server configuration is incomplete'); + }); + + it('returns server info on successful test', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.testConnection.mockResolvedValue({ + success: true, + info: { + platform: 'Plex', + version: '1.0.0', + machineIdentifier: 'machine', + }, + }); + + const service = new PlexLibraryService(); + const result = await service.testConnection(); + + expect(result.success).toBe(true); + expect(result.serverInfo).toEqual({ + name: 'Plex', + version: '1.0.0', + platform: 'Plex', + identifier: 'machine', + }); + }); + + it('returns an error when testConnection throws', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.testConnection.mockRejectedValue(new Error('boom')); + + const service = new PlexLibraryService(); + const result = await service.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toBe('boom'); + }); + + it('maps libraries and items', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.getLibraries.mockResolvedValue([ + { id: 'lib-1', title: 'Audiobooks', type: 'artist', itemCount: 5 }, + ]); + plexServiceMock.getLibraryContent.mockResolvedValue([ + { + ratingKey: 'rk-1', + guid: 'com.plexapp.agents.audible://B00ABC1234?lang=en', + title: 'Title', + author: 'Author', + narrator: 'Narrator', + summary: 'Summary', + thumb: '/thumb', + duration: 120000, + year: 2020, + addedAt: 1700000000, + updatedAt: 1700000100, + }, + ]); + + const service = new PlexLibraryService(); + const libs = await service.getLibraries(); + const items = await service.getLibraryItems('lib-1'); + + expect(libs).toEqual([{ id: 'lib-1', name: 'Audiobooks', type: 'artist', itemCount: 5 }]); + expect(items[0]).toEqual({ + id: 'rk-1', + externalId: 'com.plexapp.agents.audible://B00ABC1234?lang=en', + title: 'Title', + author: 'Author', + narrator: 'Narrator', + description: 'Summary', + coverUrl: '/thumb', + duration: 120, + asin: 'B00ABC1234', + isbn: undefined, + year: 2020, + addedAt: new Date(1700000000 * 1000), + updatedAt: new Date(1700000100 * 1000), + }); + }); + + it('returns null for getItem when metadata is unavailable', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.getItemMetadata.mockResolvedValue({ userRating: 4 }); + + const service = new PlexLibraryService(); + const item = await service.getItem('rk-1'); + + expect(item).toBeNull(); + }); + + it('triggers Plex scans and searches', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.searchLibrary.mockResolvedValue([ + { + ratingKey: 'rk-2', + guid: 'plex://album/abc', + title: 'Search Title', + author: 'Search Author', + addedAt: 1700000000, + updatedAt: 1700000000, + }, + ]); + plexServiceMock.scanLibrary.mockResolvedValue(undefined); + + const service = new PlexLibraryService(); + const results = await service.searchItems('lib-1', 'Search'); + await service.triggerLibraryScan('lib-1'); + + expect(results[0].title).toBe('Search Title'); + expect(results[0].asin).toBeUndefined(); + expect(plexServiceMock.scanLibrary).toHaveBeenCalledWith('http://plex', 'token', 'lib-1'); + }); + + it('maps recently added items with missing duration', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.getRecentlyAdded.mockResolvedValue([ + { + ratingKey: 'rk-3', + guid: 'plex://album/xyz', + title: 'Recent Title', + author: 'Author', + addedAt: 1700000000, + updatedAt: 1700000100, + }, + ]); + + const service = new PlexLibraryService(); + const items = await service.getRecentlyAdded('lib-1', 5); + + expect(items[0]).toEqual(expect.objectContaining({ + id: 'rk-3', + title: 'Recent Title', + asin: undefined, + duration: undefined, + })); + }); + + it('throws when server info cannot be retrieved', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.testConnection.mockResolvedValue({ success: false, message: 'down' }); + + const service = new PlexLibraryService(); + + await expect(service.getServerInfo()).rejects.toThrow('Failed to get server information'); + }); + + it('throws when libraries are fetched without config', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null }); + + const service = new PlexLibraryService(); + + await expect(service.getLibraries()).rejects.toThrow('Plex server configuration is incomplete'); + }); + + it('returns null when getItem metadata lookup fails', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' }); + plexServiceMock.getItemMetadata.mockRejectedValue(new Error('boom')); + + const service = new PlexLibraryService(); + const item = await service.getItem('rk-2'); + + expect(item).toBeNull(); + }); + + it('throws when triggerLibraryScan is called without config', async () => { + configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null }); + + const service = new PlexLibraryService(); + + await expect(service.triggerLibraryScan('lib-1')).rejects.toThrow('Plex server configuration is incomplete'); + }); +}); diff --git a/tests/services/request-delete.service.test.ts b/tests/services/request-delete.service.test.ts new file mode 100644 index 0000000..2a27773 --- /dev/null +++ b/tests/services/request-delete.service.test.ts @@ -0,0 +1,310 @@ +/** + * Component: Request Delete Service Tests + * Documentation: documentation/admin-features/request-deletion.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import path from 'path'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); +const fsMock = { + access: vi.fn(), + rm: vi.fn(), +}; +const configServiceMock = { + get: vi.fn(), + getBackendMode: vi.fn(), +}; +const qbtMock = { + getTorrent: vi.fn(), + deleteTorrent: vi.fn(), +}; +const sabMock = { + deleteNZB: vi.fn(), +}; + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('fs/promises', () => fsMock); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/integrations/qbittorrent.service', () => ({ + getQBittorrentService: async () => qbtMock, +})); + +vi.mock('@/lib/integrations/sabnzbd.service', () => ({ + getSABnzbdService: async () => sabMock, +})); + +describe('deleteRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns not found when request is missing', async () => { + prismaMock.request.findFirst.mockResolvedValue(null); + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + + const result = await deleteRequest('req-1', 'admin-1'); + + expect(result.success).toBe(false); + expect(result.error).toBe('NotFound'); + }); + + it('deletes completed qBittorrent downloads when seeding requirement met', async () => { + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-1', + audiobook: { + id: 'ab-1', + title: 'Book', + author: 'Author', + audibleAsin: 'ASIN1', + plexGuid: 'plex-1', + absItemId: null, + }, + downloadHistory: [ + { + torrentHash: 'hash-1', + indexerName: 'IndexerA', + downloadStatus: 'completed', + }, + ], + }); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 1 }]); + } + if (key === 'media_dir') { + return '/media'; + } + return null; + }); + configServiceMock.getBackendMode.mockResolvedValue('plex'); + qbtMock.getTorrent.mockResolvedValue({ + name: 'Book', + seeding_time: 120, + }); + prismaMock.audibleCache.findUnique.mockResolvedValue({ + releaseDate: '2021-01-01T00:00:00.000Z', + }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { id: 'lib-1', title: 'Book', author: 'Author' }, + ]); + fsMock.access.mockResolvedValue(undefined); + fsMock.rm.mockResolvedValue(undefined); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.update.mockResolvedValue({}); + + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + const result = await deleteRequest('req-1', 'admin-1'); + + expect(result.success).toBe(true); + expect(result.torrentsRemoved).toBe(1); + expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true); + expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'lib-1' } }); + + const expectedPath = path.join('/media', 'Author', 'Book (2021) ASIN1'); + expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true }); + }); + + it('removes SABnzbd downloads and continues cleanup', async () => { + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-2', + audiobook: { + id: 'ab-2', + title: 'Book Two', + author: 'Author', + audibleAsin: null, + plexGuid: 'plex-2', + absItemId: null, + }, + downloadHistory: [ + { + nzbId: 'nzb-1', + indexerName: 'IndexerB', + downloadStatus: 'completed', + }, + ], + }); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return JSON.stringify([{ name: 'IndexerB', seedingTimeMinutes: 0 }]); + } + if (key === 'media_dir') { + return '/media'; + } + return null; + }); + configServiceMock.getBackendMode.mockResolvedValue('plex'); + sabMock.deleteNZB.mockResolvedValue(undefined); + fsMock.access.mockResolvedValue(undefined); + fsMock.rm.mockResolvedValue(undefined); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.update.mockResolvedValue({}); + + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + const result = await deleteRequest('req-2', 'admin-1'); + + expect(result.success).toBe(true); + expect(result.torrentsRemoved).toBe(1); + expect(sabMock.deleteNZB).toHaveBeenCalledWith('nzb-1', true); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ deletedBy: 'admin-1' }), + }) + ); + }); + + it('keeps torrents seeding when requirement is not met and deletes fallback path', async () => { + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-3', + audiobook: { + id: 'ab-3', + title: 'Book Three', + author: 'Author Name', + audibleAsin: 'ASIN3', + plexGuid: 'plex-3', + absItemId: null, + }, + downloadHistory: [ + { + torrentHash: 'hash-3', + indexerName: 'IndexerC', + downloadStatus: 'completed', + }, + ], + }); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return JSON.stringify([{ name: 'IndexerC', seedingTimeMinutes: 10 }]); + } + if (key === 'media_dir') { + return '/media'; + } + return null; + }); + configServiceMock.getBackendMode.mockResolvedValue('plex'); + qbtMock.getTorrent.mockResolvedValue({ + name: 'Book Three', + seeding_time: 60, + }); + prismaMock.audibleCache.findUnique.mockResolvedValue({ + releaseDate: '2020-01-01T00:00:00.000Z', + }); + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { id: 'lib-2', title: 'Book Three', author: 'Other' }, + ]); + fsMock.access + .mockRejectedValueOnce(new Error('missing')) + .mockResolvedValueOnce(undefined); + fsMock.rm.mockResolvedValue(undefined); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.update.mockResolvedValue({}); + + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + const result = await deleteRequest('req-3', 'admin-2'); + + expect(result.torrentsKeptSeeding).toBe(1); + expect(qbtMock.deleteTorrent).not.toHaveBeenCalled(); + + const fallbackPath = path.join('/media', 'Author Name', 'Book Three'); + expect(fsMock.rm).toHaveBeenCalledWith(fallbackPath, { recursive: true, force: true }); + }); + + it('keeps torrents for unlimited seeding when no config is present', async () => { + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-4', + audiobook: { + id: 'ab-4', + title: 'Book Four', + author: 'Author', + audibleAsin: null, + plexGuid: 'plex-4', + absItemId: null, + }, + downloadHistory: [ + { + torrentHash: 'hash-4', + indexerName: 'IndexerD', + downloadStatus: 'completed', + }, + ], + }); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return null; + } + if (key === 'media_dir') { + return '/media'; + } + return null; + }); + configServiceMock.getBackendMode.mockResolvedValue('plex'); + qbtMock.getTorrent.mockResolvedValue({ + name: 'Book Four', + seeding_time: 0, + }); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + fsMock.access.mockRejectedValue(new Error('missing')); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.update.mockResolvedValue({}); + + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + const result = await deleteRequest('req-4', 'admin-3'); + + expect(result.torrentsKeptUnlimited).toBe(1); + expect(qbtMock.deleteTorrent).not.toHaveBeenCalled(); + }); + + it('clears audiobookshelf linkage when SABnzbd delete fails', async () => { + prismaMock.request.findFirst.mockResolvedValue({ + id: 'req-5', + audiobook: { + id: 'ab-5', + title: 'Book Five', + author: 'Author', + audibleAsin: null, + plexGuid: null, + absItemId: 'abs-5', + }, + downloadHistory: [ + { + nzbId: 'nzb-5', + indexerName: 'IndexerE', + downloadStatus: 'completed', + }, + ], + }); + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'prowlarr_indexers') { + return JSON.stringify([{ name: 'IndexerE', seedingTimeMinutes: 0 }]); + } + if (key === 'media_dir') { + return '/media'; + } + return null; + }); + configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf'); + sabMock.deleteNZB.mockRejectedValue(new Error('missing')); + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + fsMock.access.mockRejectedValue(new Error('missing')); + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.update.mockResolvedValue({}); + + const { deleteRequest } = await import('@/lib/services/request-delete.service'); + const result = await deleteRequest('req-5', 'admin-5'); + + expect(result.success).toBe(true); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith({ + where: { id: 'ab-5' }, + data: expect.objectContaining({ absItemId: null }), + }); + }); +}); diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts new file mode 100644 index 0000000..5724f5d --- /dev/null +++ b/tests/services/scheduler.service.test.ts @@ -0,0 +1,493 @@ +/** + * Component: Scheduler Service Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +const jobQueueMock = vi.hoisted(() => ({ + addRepeatableJob: vi.fn(), + removeRepeatableJob: vi.fn(), + addPlexScanJob: vi.fn(), + addPlexRecentlyAddedJob: vi.fn(), + addAudibleRefreshJob: vi.fn(), + addRetryMissingTorrentsJob: vi.fn(), + addRetryFailedImportsJob: vi.fn(), + addCleanupSeededTorrentsJob: vi.fn(), + addMonitorRssFeedsJob: vi.fn(), +})); + +const configServiceMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + getMany: vi.fn(), +})); + +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configServiceMock, +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +describe('SchedulerService', () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.scheduledJob.findFirst.mockReset(); + prismaMock.scheduledJob.create.mockReset(); + prismaMock.scheduledJob.findMany.mockReset(); + prismaMock.scheduledJob.findUnique.mockReset(); + prismaMock.scheduledJob.update.mockReset(); + prismaMock.scheduledJob.delete.mockReset(); + configServiceMock.getBackendMode.mockReset(); + configServiceMock.getMany.mockReset(); + }); + + it('initializes defaults and schedules enabled jobs', async () => { + prismaMock.scheduledJob.findFirst.mockResolvedValue(null); + prismaMock.scheduledJob.create.mockResolvedValue({}); + prismaMock.scheduledJob.findMany + .mockResolvedValueOnce([ + { + id: 'job-1', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: true, + }, + ]) + .mockResolvedValueOnce([]); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.start(); + + expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(7); + expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith( + 'audible_refresh', + { scheduledJobId: 'job-1' }, + '0 0 * * *', + 'scheduled-job-1' + ); + }); + + it('rejects invalid cron expressions', async () => { + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + + await expect( + service.createScheduledJob({ + name: 'Bad job', + type: 'audible_refresh', + schedule: 'bad', + }) + ).rejects.toThrow('Invalid cron expression format'); + }); + + it('creates and schedules enabled jobs', async () => { + prismaMock.scheduledJob.create.mockResolvedValue({ + id: 'job-2', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: true, + payload: {}, + }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.createScheduledJob({ + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: true, + }); + + expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith( + 'audible_refresh', + { scheduledJobId: 'job-2' }, + '0 0 * * *', + 'scheduled-job-2' + ); + }); + + it('returns scheduled jobs and single jobs', async () => { + prismaMock.scheduledJob.findMany.mockResolvedValue([{ id: 'job-2' }]); + prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-2' }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + const jobs = await service.getScheduledJobs(); + const job = await service.getScheduledJob('job-2'); + + expect(prismaMock.scheduledJob.findMany).toHaveBeenCalledWith({ orderBy: { name: 'asc' } }); + expect(prismaMock.scheduledJob.findUnique).toHaveBeenCalledWith({ where: { id: 'job-2' } }); + expect(jobs).toEqual([{ id: 'job-2' }]); + expect(job).toEqual({ id: 'job-2' }); + }); + + it('updates jobs and reschedules when enabled', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-3', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: true, + payload: {}, + }); + prismaMock.scheduledJob.update.mockResolvedValue({ + id: 'job-3', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '*/15 * * * *', + enabled: true, + payload: {}, + }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.updateScheduledJob('job-3', { schedule: '*/15 * * * *' }); + + expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith( + 'audible_refresh', + '0 0 * * *', + 'scheduled-job-3' + ); + expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith( + 'audible_refresh', + { scheduledJobId: 'job-3' }, + '*/15 * * * *', + 'scheduled-job-3' + ); + }); + + it('unschedules jobs when disabling updates', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-3b', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: true, + payload: {}, + }); + prismaMock.scheduledJob.update.mockResolvedValue({ + id: 'job-3b', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: false, + payload: {}, + }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.updateScheduledJob('job-3b', { enabled: false }); + + expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith( + 'audible_refresh', + '0 0 * * *', + 'scheduled-job-3b' + ); + expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled(); + }); + + it('triggers Plex scan jobs with validated config', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-4', + name: 'Library Scan', + type: 'plex_library_scan', + schedule: '0 */6 * * *', + enabled: true, + payload: {}, + }); + configServiceMock.getBackendMode.mockResolvedValue('plex'); + configServiceMock.getMany.mockResolvedValue({ + plex_url: 'http://plex', + plex_token: 'token', + plex_audiobook_library_id: 'lib-1', + }); + jobQueueMock.addPlexScanJob.mockResolvedValue('bull-1'); + prismaMock.scheduledJob.update.mockResolvedValue({}); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + const jobId = await service.triggerJobNow('job-4'); + + expect(jobId).toBe('bull-1'); + expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('lib-1', undefined, undefined); + expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({ + where: { id: 'job-4' }, + data: { + lastRun: expect.any(Date), + lastRunJobId: 'bull-1', + }, + }); + }); + + it('triggers Audiobookshelf scans when configured', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-4b', + name: 'Library Scan', + type: 'plex_library_scan', + schedule: '0 */6 * * *', + enabled: true, + payload: { libraryId: 'abs-lib' }, + }); + configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configServiceMock.getMany.mockResolvedValue({ + 'audiobookshelf.server_url': 'http://abs', + 'audiobookshelf.api_token': 'token', + 'audiobookshelf.library_id': 'abs-lib-2', + }); + jobQueueMock.addPlexScanJob.mockResolvedValue('bull-abs'); + prismaMock.scheduledJob.update.mockResolvedValue({}); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + const jobId = await service.triggerJobNow('job-4b'); + + expect(jobId).toBe('bull-abs'); + expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('abs-lib', undefined, undefined); + }); + + it('throws on unknown scheduled job types', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-5', + name: 'Mystery', + type: 'unknown', + schedule: '* * * * *', + enabled: true, + payload: {}, + }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + + await expect(service.triggerJobNow('job-5')).rejects.toThrow('Unknown job type'); + }); + + it.each([ + ['plex_recently_added_check', 'addPlexRecentlyAddedJob'], + ['audible_refresh', 'addAudibleRefreshJob'], + ['retry_missing_torrents', 'addRetryMissingTorrentsJob'], + ['retry_failed_imports', 'addRetryFailedImportsJob'], + ['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'], + ['monitor_rss_feeds', 'addMonitorRssFeedsJob'], + ])('triggers %s jobs with job queue', async (type, queueMethod) => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-type', + name: 'Job', + type, + schedule: '* * * * *', + enabled: true, + payload: {}, + }); + (jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type'); + prismaMock.scheduledJob.update.mockResolvedValue({}); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + const jobId = await service.triggerJobNow('job-type'); + + expect(jobId).toBe('bull-type'); + expect((jobQueueMock as any)[queueMethod]).toHaveBeenCalledWith('job-type'); + }); + + it('parses cron intervals for common patterns', async () => { + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + + expect((service as any).getIntervalFromCron('*/15 * * * *')).toBe(15 * 60 * 1000); + expect((service as any).getIntervalFromCron('0 */6 * * *')).toBe(6 * 60 * 60 * 1000); + expect((service as any).getIntervalFromCron('0 4 * * *')).toBe(24 * 60 * 60 * 1000); + expect((service as any).getIntervalFromCron('0 4 * * 1')).toBe(7 * 24 * 60 * 60 * 1000); + expect((service as any).getIntervalFromCron('invalid cron')).toBeNull(); + }); + + it('does not schedule disabled jobs on creation', async () => { + prismaMock.scheduledJob.create.mockResolvedValue({ + id: 'job-6', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: false, + payload: {}, + }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.createScheduledJob({ + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: false, + }); + + expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled(); + }); + + it('does not reschedule when updated job stays disabled', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-7', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: false, + payload: {}, + }); + prismaMock.scheduledJob.update.mockResolvedValue({ + id: 'job-7', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 1 * * *', + enabled: false, + payload: {}, + }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.updateScheduledJob('job-7', { schedule: '0 1 * * *', enabled: false }); + + expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled(); + expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled(); + }); + + it('unschedules jobs when deleted', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-8', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: true, + payload: {}, + }); + prismaMock.scheduledJob.delete.mockResolvedValue({}); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.deleteScheduledJob('job-8'); + + expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith( + 'audible_refresh', + '0 0 * * *', + 'scheduled-job-8' + ); + expect(prismaMock.scheduledJob.delete).toHaveBeenCalled(); + }); + + it('deletes disabled jobs without unscheduling', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-8b', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '0 0 * * *', + enabled: false, + payload: {}, + }); + prismaMock.scheduledJob.delete.mockResolvedValue({}); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + await service.deleteScheduledJob('job-8b'); + + expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled(); + expect(prismaMock.scheduledJob.delete).toHaveBeenCalled(); + }); + + it('triggers overdue jobs based on lastRun and schedule', async () => { + const overdueJob = { + id: 'job-9', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '*/5 * * * *', + enabled: true, + payload: {}, + lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + }; + + prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockResolvedValue('bull-9'); + + await (service as any).triggerOverdueJobs(); + + expect(triggerSpy).toHaveBeenCalledWith('job-9'); + }); + + it('logs and continues when overdue jobs fail to trigger', async () => { + const overdueJob = { + id: 'job-9b', + name: 'Audible Data Refresh', + type: 'audible_refresh', + schedule: '*/5 * * * *', + enabled: true, + payload: {}, + lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + }; + + prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockRejectedValue(new Error('fail')); + + await expect((service as any).triggerOverdueJobs()).resolves.toBeUndefined(); + expect(triggerSpy).toHaveBeenCalledWith('job-9b'); + }); + it('identifies overdue jobs when lastRun is missing', async () => { + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + + const overdue = (service as any).isJobOverdue({ + name: 'No last run', + schedule: '0 * * * *', + lastRun: null, + }); + + expect(overdue).toBe(true); + }); + + it('returns false for unparseable cron intervals', async () => { + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + + const overdue = (service as any).isJobOverdue({ + name: 'Bad cron', + schedule: 'bad', + lastRun: new Date().toISOString(), + }); + + expect(overdue).toBe(false); + }); + + it('throws when Audiobookshelf scan configuration is missing', async () => { + prismaMock.scheduledJob.findUnique.mockResolvedValue({ + id: 'job-10', + name: 'Library Scan', + type: 'plex_library_scan', + schedule: '0 */6 * * *', + enabled: true, + payload: {}, + }); + configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configServiceMock.getMany.mockResolvedValue({ + 'audiobookshelf.server_url': null, + 'audiobookshelf.api_token': null, + 'audiobookshelf.library_id': null, + }); + + const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const service = new SchedulerService(); + + await expect(service.triggerJobNow('job-10')).rejects.toThrow('Audiobookshelf is not configured'); + }); +}); diff --git a/tests/services/thumbnail-cache.service.test.ts b/tests/services/thumbnail-cache.service.test.ts new file mode 100644 index 0000000..e51b816 --- /dev/null +++ b/tests/services/thumbnail-cache.service.test.ts @@ -0,0 +1,125 @@ +/** + * Component: Thumbnail Cache Service Tests + * Documentation: documentation/integrations/audible.md + */ + +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ThumbnailCacheService } from '@/lib/services/thumbnail-cache.service'; + +const fsMock = vi.hoisted(() => ({ + mkdir: vi.fn(), + access: vi.fn(), + writeFile: vi.fn(), + readdir: vi.fn(), + unlink: vi.fn(), +})); + +const axiosMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + default: fsMock, + ...fsMock, +})); +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +describe('ThumbnailCacheService', () => { + beforeEach(() => { + vi.clearAllMocks(); + fsMock.mkdir.mockReset(); + fsMock.access.mockReset(); + fsMock.writeFile.mockReset(); + fsMock.readdir.mockReset(); + fsMock.unlink.mockReset(); + axiosMock.get.mockReset(); + }); + + it('returns null when missing ASIN or URL', async () => { + const service = new ThumbnailCacheService(); + + expect(await service.cacheThumbnail('', 'http://example.com/x.jpg')).toBeNull(); + expect(await service.cacheThumbnail('ASIN', '')).toBeNull(); + }); + + it('returns cached path when file already exists', async () => { + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.access.mockResolvedValue(undefined); + + const service = new ThumbnailCacheService(); + const result = await service.cacheThumbnail('ASIN1', 'https://img.example.com/cover.jpg'); + + expect(result).toBe(path.join('/app/cache/thumbnails', 'ASIN1.jpg')); + expect(axiosMock.get).not.toHaveBeenCalled(); + }); + + it('skips non-image content types', async () => { + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.access.mockRejectedValue(new Error('missing')); + axiosMock.get.mockResolvedValue({ + headers: { 'content-type': 'text/html' }, + data: Buffer.from('nope'), + }); + + const service = new ThumbnailCacheService(); + const result = await service.cacheThumbnail('ASIN2', 'https://img.example.com/cover.png'); + + expect(result).toBeNull(); + expect(fsMock.writeFile).not.toHaveBeenCalled(); + }); + + it('downloads and caches image content', async () => { + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.access.mockRejectedValue(new Error('missing')); + axiosMock.get.mockResolvedValue({ + headers: { 'content-type': 'image/jpeg' }, + data: Buffer.from([1, 2, 3]), + }); + fsMock.writeFile.mockResolvedValue(undefined); + + const service = new ThumbnailCacheService(); + const result = await service.cacheThumbnail('ASIN3', 'https://img.example.com/cover.jpeg'); + + expect(result).toBe(path.join('/app/cache/thumbnails', 'ASIN3.jpeg')); + expect(fsMock.writeFile).toHaveBeenCalled(); + }); + + it('deletes thumbnails for a specific ASIN', async () => { + fsMock.readdir.mockResolvedValue(['ASIN4.jpg', 'ASIN4.png', 'OTHER.jpg']); + fsMock.unlink.mockResolvedValue(undefined); + + const service = new ThumbnailCacheService(); + await service.deleteThumbnail('ASIN4'); + + expect(fsMock.unlink).toHaveBeenCalledTimes(2); + }); + + it('cleans up unused thumbnails', async () => { + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.readdir.mockResolvedValue(['KEEP.jpg', 'DROP.jpg']); + fsMock.unlink.mockResolvedValue(undefined); + + const service = new ThumbnailCacheService(); + const deleted = await service.cleanupUnusedThumbnails(new Set(['KEEP'])); + + expect(deleted).toBe(1); + expect(fsMock.unlink).toHaveBeenCalledTimes(1); + }); + + it('maps cached paths for serving', () => { + const service = new ThumbnailCacheService(); + + expect(service.getCachedPath(null)).toBeNull(); + expect(service.getCachedPath('/app/cache/thumbnails/ASIN.jpg')).toBe('/cache/thumbnails/ASIN.jpg'); + }); + + it('exposes the cache directory', () => { + const service = new ThumbnailCacheService(); + + expect(service.getCacheDirectory()).toBe('/app/cache/thumbnails'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..71ee325 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,23 @@ +/** + * Component: Test Setup + * Documentation: documentation/README.md + */ + +import { beforeAll, afterAll, vi } from 'vitest'; +import '@testing-library/jest-dom'; + +beforeAll(() => { + process.env.NODE_ENV = 'test'; + process.env.TZ = 'UTC'; + + if (!globalThis.fetch) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = () => { + throw new Error('fetch was called without a mock in tests'); + }; + } +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); diff --git a/tests/utils/api.test.ts b/tests/utils/api.test.ts new file mode 100644 index 0000000..770d8fd --- /dev/null +++ b/tests/utils/api.test.ts @@ -0,0 +1,164 @@ +/** + * Component: API Utility Functions Tests + * Documentation: documentation/frontend/utilities.md + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const jwtState = vi.hoisted(() => ({ + isTokenExpired: vi.fn(), +})); + +vi.mock('@/lib/utils/jwt-client', () => ({ + isTokenExpired: jwtState.isTokenExpired, +})); + +describe('api utilities', () => { + const originalWindow = globalThis.window; + const storage = new Map(); + let fetchMock: ReturnType; + + const localStorageMock = { + getItem: (key: string) => (storage.has(key) ? storage.get(key)! : null), + setItem: (key: string, value: string) => { + storage.set(key, String(value)); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => { + storage.clear(); + }, + }; + + const createResponse = (status: number, body: unknown, ok = status >= 200 && status < 300) => ({ + ok, + status, + json: vi.fn().mockResolvedValue(body), + }); + + beforeEach(() => { + vi.resetModules(); + storage.clear(); + fetchMock = vi.fn(); + + (globalThis as any).localStorage = localStorageMock; + (globalThis as any).fetch = fetchMock; + + jwtState.isTokenExpired.mockReset(); + jwtState.isTokenExpired.mockReturnValue(false); + }); + + afterEach(() => { + globalThis.window = originalWindow; + }); + + it('adds authorization headers when access token exists', async () => { + const { fetchWithAuth } = await import('@/lib/utils/api'); + + localStorageMock.setItem('accessToken', 'token-1'); + fetchMock.mockResolvedValue(createResponse(200, {})); + + await fetchWithAuth('/api/data', { headers: { 'X-Test': '1' } }); + + const [, init] = fetchMock.mock.calls[0]; + expect(init.headers).toEqual({ + 'X-Test': '1', + 'Authorization': 'Bearer token-1', + }); + }); + + it('refreshes tokens on 401 and retries the request', async () => { + const { fetchWithAuth } = await import('@/lib/utils/api'); + + localStorageMock.setItem('accessToken', 'token-old'); + localStorageMock.setItem('refreshToken', 'refresh-1'); + + let call = 0; + fetchMock.mockImplementation(async (url: string) => { + if (url === '/api/auth/refresh') { + return createResponse(200, { accessToken: 'token-new' }, true); + } + + call += 1; + if (call === 1) { + return createResponse(401, {}, false); + } + return createResponse(200, { ok: true }, true); + }); + + const response = await fetchWithAuth('/api/data'); + + expect(response.status).toBe(200); + expect(localStorageMock.getItem('accessToken')).toBe('token-new'); + + const retryCall = fetchMock.mock.calls.find((entry: any[]) => entry[0] === '/api/data' && entry[1]?.headers?.Authorization === 'Bearer token-new'); + expect(retryCall).toBeDefined(); + }); + + it('logs out when refresh token is expired', async () => { + const { fetchWithAuth } = await import('@/lib/utils/api'); + + jwtState.isTokenExpired.mockReturnValue(true); + localStorageMock.setItem('accessToken', 'token-old'); + localStorageMock.setItem('refreshToken', 'refresh-1'); + localStorageMock.setItem('user', 'user'); + + globalThis.window = { location: { pathname: '/requests', href: '' } } as any; + + fetchMock.mockResolvedValue(createResponse(401, {}, false)); + + await fetchWithAuth('/api/data'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(localStorageMock.getItem('accessToken')).toBeNull(); + expect(localStorageMock.getItem('refreshToken')).toBeNull(); + expect(globalThis.window.location.href).toBe('/login?redirect=%2Frequests'); + }); + + it('logs out when refreshed token still yields 401', async () => { + const { fetchWithAuth } = await import('@/lib/utils/api'); + + localStorageMock.setItem('accessToken', 'token-old'); + localStorageMock.setItem('refreshToken', 'refresh-1'); + localStorageMock.setItem('user', 'user'); + + globalThis.window = { location: { pathname: '/requests', href: '' } } as any; + + let call = 0; + fetchMock.mockImplementation(async (url: string) => { + if (url === '/api/auth/refresh') { + return createResponse(200, { accessToken: 'token-new' }, true); + } + call += 1; + if (call === 1) { + return createResponse(401, {}, false); + } + return createResponse(401, {}, false); + }); + + await fetchWithAuth('/api/data'); + + expect(localStorageMock.getItem('accessToken')).toBeNull(); + expect(localStorageMock.getItem('refreshToken')).toBeNull(); + expect(globalThis.window.location.href).toBe('/login?redirect=%2Frequests'); + }); + + it('fetches JSON data successfully', async () => { + const { fetchJSON } = await import('@/lib/utils/api'); + + fetchMock.mockResolvedValue(createResponse(200, { ok: true }, true)); + + const result = await fetchJSON('/api/data'); + + expect(result).toEqual({ ok: true }); + }); + + it('throws a useful error when JSON request fails', async () => { + const { fetchJSON } = await import('@/lib/utils/api'); + + fetchMock.mockResolvedValue(createResponse(500, { message: 'bad' }, false)); + + await expect(fetchJSON('/api/data')).rejects.toThrow('bad'); + }); +}); diff --git a/tests/utils/audiobook-matcher.test.ts b/tests/utils/audiobook-matcher.test.ts new file mode 100644 index 0000000..a8c6876 --- /dev/null +++ b/tests/utils/audiobook-matcher.test.ts @@ -0,0 +1,152 @@ +/** + * Component: Audiobook Matcher Tests + * Documentation: documentation/integrations/audible.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +describe('audiobook-matcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns ASIN exact match from dedicated field', async () => { + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + plexGuid: 'guid-1', + plexRatingKey: 'rating-1', + title: 'Test Book', + author: 'Test Author', + asin: 'B00TEST123', + isbn: null, + }, + ]); + + const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher'); + const match = await findPlexMatch({ + asin: 'B00TEST123', + title: 'Test Book', + author: 'Test Author', + }); + + expect(match?.plexGuid).toBe('guid-1'); + }); + + it('rejects candidates with mismatched ASINs in plexGuid', async () => { + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + plexGuid: 'com.plexapp.agents.audible://B00WRONG999', + plexRatingKey: null, + title: 'Test Book', + author: 'Test Author', + asin: null, + isbn: null, + }, + ]); + + const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher'); + const match = await findPlexMatch({ + asin: 'B00RIGHT123', + title: 'Test Book', + author: 'Test Author', + }); + + expect(match).toBeNull(); + }); + + it('uses narrator matching when author match is weak', async () => { + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + plexGuid: 'guid-narrator', + plexRatingKey: null, + title: 'Great Book', + author: 'Jane Narrator', + asin: null, + isbn: null, + }, + ]); + + const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher'); + const match = await findPlexMatch({ + asin: 'B00TEST999', + title: 'Great Book', + author: 'Different Author', + narrator: 'Jane Narrator', + }); + + expect(match?.plexGuid).toBe('guid-narrator'); + }); + + it('matches library items by ASIN, ISBN, then fuzzy match', async () => { + const items = [ + { id: '1', externalId: 'g1', title: 'Alpha', author: 'Author A', asin: 'ASIN1' }, + { id: '2', externalId: 'g2', title: 'Beta', author: 'Author B', isbn: '978-1-23456-789-7' }, + { id: '3', externalId: 'g3', title: 'Gamma Book', author: 'Author C' }, + ]; + + const { matchAudiobook } = await import('@/lib/utils/audiobook-matcher'); + const asinMatch = matchAudiobook({ title: 'x', author: 'y', asin: 'ASIN1' }, items); + expect(asinMatch?.externalId).toBe('g1'); + + const isbnMatch = matchAudiobook({ title: 'x', author: 'y', isbn: '9781234567897' }, items); + expect(isbnMatch?.externalId).toBe('g2'); + + const fuzzyMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items); + expect(fuzzyMatch?.externalId).toBe('g3'); + }); + + it('enriches audiobooks with availability and request status', async () => { + prismaMock.plexLibrary.findMany + .mockResolvedValueOnce([ + { + plexGuid: 'guid-1', + plexRatingKey: null, + title: 'Book One', + author: 'Author One', + asin: 'ASIN1', + isbn: null, + }, + ]) + .mockResolvedValueOnce([]); + + prismaMock.audiobook.findMany.mockResolvedValue([ + { + id: 'a1', + audibleAsin: 'ASIN1', + requests: [ + { + id: 'r1', + status: 'downloading', + userId: 'other-user', + user: { plexUsername: 'OtherUser' }, + }, + ], + }, + ]); + + const { enrichAudiobooksWithMatches } = await import('@/lib/utils/audiobook-matcher'); + const results = await enrichAudiobooksWithMatches( + [ + { asin: 'ASIN1', title: 'Book One', author: 'Author One' }, + { asin: 'ASIN2', title: 'Book Two', author: 'Author Two' }, + ], + 'current-user' + ); + + expect(results[0].isAvailable).toBe(true); + expect(results[0].isRequested).toBe(true); + expect(results[0].requestedByUsername).toBe('OtherUser'); + + expect(results[1].isAvailable).toBe(false); + expect(results[1].isRequested).toBe(false); + }); +}); + + diff --git a/tests/utils/chapter-merger.test.ts b/tests/utils/chapter-merger.test.ts new file mode 100644 index 0000000..ae51b85 --- /dev/null +++ b/tests/utils/chapter-merger.test.ts @@ -0,0 +1,623 @@ +/** + * Component: Chapter Merger Utility Tests + * Documentation: documentation/features/chapter-merging.md + */ + +import { EventEmitter } from 'events'; +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + analyzeChapterFiles, + checkDiskSpace, + detectChapterFiles, + estimateOutputSize, + formatDuration, + mergeChapters, + probeAudioFile, +} from '@/lib/utils/chapter-merger'; + +const execState = vi.hoisted(() => { + const state = { + handler: null as null | ((command: string) => { stdout?: string; error?: Error }), + }; + const custom = Symbol.for('nodejs.util.promisify.custom'); + const exec = vi.fn(); + (exec as any)[custom] = (command: string) => + new Promise((resolve, reject) => { + const result = state.handler ? state.handler(command) : { stdout: '' }; + if (result.error) { + reject(result.error); + return; + } + resolve({ stdout: result.stdout ?? '', stderr: '' }); + }); + return { exec, state }; +}); +const spawnMock = vi.hoisted(() => vi.fn()); +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + unlink: vi.fn(), + constants: { R_OK: 4 }, +})); + +vi.mock('child_process', () => ({ + exec: execState.exec, + spawn: spawnMock, +})); + +vi.mock('fs/promises', () => ({ + default: fsMock, + ...fsMock, +})); + +function createSpawnProcess(exitCode = 0, stderrData = '') { + const proc = new EventEmitter() as EventEmitter & { + stderr: EventEmitter; + kill: () => void; + }; + proc.stderr = new EventEmitter(); + proc.kill = vi.fn(); + + setImmediate(() => { + if (stderrData) { + proc.stderr.emit('data', Buffer.from(stderrData)); + } + proc.emit('close', exitCode); + }); + + return proc; +} + +function mockExecImplementation(handlers: (command: string) => { stdout?: string; error?: Error }) { + execState.state.handler = handlers; +} + +describe('chapter merger', () => { + beforeEach(() => { + vi.clearAllMocks(); + execState.state.handler = null; + }); + + it('detects when chapter merging should be skipped', async () => { + await expect(detectChapterFiles(['one.mp3', 'two.mp3'])).resolves.toBe(false); + await expect(detectChapterFiles(['one.mp3', 'two.m4b', 'three.mp3'])).resolves.toBe(false); + await expect(detectChapterFiles(['one.wav', 'two.wav', 'three.wav'])).resolves.toBe(false); + }); + + it('detects eligible chapter files', async () => { + await expect(detectChapterFiles(['one.mp3', 'two.mp3', 'three.mp3'])).resolves.toBe(true); + }); + + it('orders chapters by metadata when track numbers are sequential', async () => { + const files = ['/tmp/b.mp3', '/tmp/a.mp3', '/tmp/c.mp3']; + const probeMap: Record = { + '/tmp/b.mp3': { duration: 60, bitrate: 128000, track: 1 }, + '/tmp/a.mp3': { duration: 60, bitrate: 128000, track: 2 }, + '/tmp/c.mp3': { duration: 60, bitrate: 128000, track: 3 }, + }; + + mockExecImplementation((command) => { + const matches = command.match(/"([^"]+)"/g) ?? []; + const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; + const probe = probeMap[filePath]; + if (!probe) { + throw new Error(`Missing probe data for ${filePath}`); + } + const payload = { + format: { + duration: String(probe.duration), + bit_rate: String(probe.bitrate), + tags: { track: String(probe.track) }, + }, + }; + return { stdout: JSON.stringify(payload) }; + }); + + const ordered = await analyzeChapterFiles(files); + + expect(ordered.map((file) => path.basename(file.path))).toEqual(['b.mp3', 'a.mp3', 'c.mp3']); + expect(ordered[0].chapterTitle).toBe('Chapter 1'); + }); + + it('orders chapters by filename when track numbers are missing', async () => { + const files = ['/tmp/02 - Middle.mp3', '/tmp/01 - Start.mp3', '/tmp/03 - End.mp3']; + const probeMap: Record = { + '/tmp/02 - Middle.mp3': { duration: 60, bitrate: 128000 }, + '/tmp/01 - Start.mp3': { duration: 60, bitrate: 128000 }, + '/tmp/03 - End.mp3': { duration: 60, bitrate: 128000 }, + }; + + mockExecImplementation((command) => { + const matches = command.match(/"([^"]+)"/g) ?? []; + const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; + const probe = probeMap[filePath]; + if (!probe) { + throw new Error(`Missing probe data for ${filePath}`); + } + const payload = { + format: { + duration: String(probe.duration), + bit_rate: String(probe.bitrate), + tags: {}, + }, + }; + return { stdout: JSON.stringify(payload) }; + }); + + const ordered = await analyzeChapterFiles(files); + + expect(ordered.map((file) => path.basename(file.path))).toEqual([ + '01 - Start.mp3', + '02 - Middle.mp3', + '03 - End.mp3', + ]); + expect(ordered[0].chapterTitle).toBe('Start'); + expect(ordered[1].chapterTitle).toBe('Middle'); + }); + + it('falls back to chapter numbers when metadata title is the book title', async () => { + const files = ['/tmp/01.mp3', '/tmp/02.mp3', '/tmp/03.mp3']; + const probeMap: Record = { + '/tmp/01.mp3': { duration: 60, bitrate: 128000, track: 1, title: 'Book Title' }, + '/tmp/02.mp3': { duration: 60, bitrate: 128000, track: 2, title: 'Book Title' }, + '/tmp/03.mp3': { duration: 60, bitrate: 128000, track: 3, title: 'Book Title' }, + }; + + mockExecImplementation((command) => { + const matches = command.match(/"([^"]+)"/g) ?? []; + const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; + const probe = probeMap[filePath]; + if (!probe) { + throw new Error(`Missing probe data for ${filePath}`); + } + const payload = { + format: { + duration: String(probe.duration), + bit_rate: String(probe.bitrate), + tags: { track: String(probe.track), title: probe.title }, + }, + }; + return { stdout: JSON.stringify(payload) }; + }); + + const ordered = await analyzeChapterFiles(files); + + expect(ordered[0].chapterTitle).toBe('Chapter 1'); + expect(ordered[1].chapterTitle).toBe('Chapter 2'); + }); + + it('uses filename order when track numbers are not sequential', async () => { + const files = ['/tmp/02 - Two.mp3', '/tmp/01 - One.mp3', '/tmp/03 - Three.mp3']; + const probeMap: Record = { + '/tmp/02 - Two.mp3': { duration: 60, bitrate: 128000, track: 2 }, + '/tmp/01 - One.mp3': { duration: 60, bitrate: 128000, track: 1 }, + '/tmp/03 - Three.mp3': { duration: 60, bitrate: 128000, track: 4 }, + }; + + mockExecImplementation((command) => { + const matches = command.match(/"([^"]+)"/g) ?? []; + const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : ''; + const probe = probeMap[filePath]; + if (!probe) { + throw new Error(`Missing probe data for ${filePath}`); + } + const payload = { + format: { + duration: String(probe.duration), + bit_rate: String(probe.bitrate), + tags: { track: String(probe.track) }, + }, + }; + return { stdout: JSON.stringify(payload) }; + }); + + const ordered = await analyzeChapterFiles(files); + + expect(ordered.map((file) => path.basename(file.path))).toEqual([ + '01 - One.mp3', + '02 - Two.mp3', + '03 - Three.mp3', + ]); + }); + + it('formats durations for logs', () => { + expect(formatDuration(65000)).toBe('1m 5s'); + expect(formatDuration(3601000)).toBe('1h 0m 1s'); + }); + + it('estimates output size with overhead', async () => { + fsMock.stat.mockImplementation(async (filePath: string) => { + if (filePath === '/tmp/one.mp3') return { size: 100 }; + if (filePath === '/tmp/two.mp3') return { size: 200 }; + throw new Error('missing'); + }); + + const size = await estimateOutputSize(['/tmp/one.mp3', '/tmp/two.mp3', '/tmp/missing.mp3']); + + expect(size).toBe(330); + }); + + it('checks disk space when df output is available', async () => { + mockExecImplementation(() => ({ stdout: '1024\n' })); + + const space = await checkDiskSpace('/tmp'); + + expect(space).toBe(1024 * 1024); + }); + + it('returns null when disk space cannot be determined', async () => { + mockExecImplementation(() => ({ error: new Error('df missing') })); + + const space = await checkDiskSpace('/tmp'); + + expect(space).toBeNull(); + }); + + it('returns an error when no chapters are provided', async () => { + const result = await mergeChapters([], { + title: 'Book', + author: 'Author', + outputPath: '/tmp/output.m4b', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No chapters'); + }); + + it('merges chapters and returns success details', async () => { + const outputPath = '/tmp/output.m4b'; + + const chapters = [ + { path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' }, + { path: '/tmp/two.mp3', filename: 'two.mp3', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, + ]; + + fsMock.access.mockResolvedValue(undefined); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + fsMock.stat.mockImplementation(async (filePath: string) => { + if (filePath === outputPath) { + return { size: 2 * 1024 * 1024 }; + } + return { size: 500 * 1024 }; + }); + + mockExecImplementation((command) => { + if (command.startsWith('ffmpeg -encoders')) { + return { stdout: 'aac encoder' }; + } + if (command.startsWith('ffprobe')) { + const payload = { + format: { + duration: '120', + bit_rate: '128000', + tags: {}, + }, + }; + return { stdout: JSON.stringify(payload) }; + } + if (command.startsWith('ffmpeg -v error')) { + return { stdout: '' }; + } + return { error: new Error(`Unexpected command: ${command}`) }; + }); + + spawnMock.mockReturnValue(createSpawnProcess(0)); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath, + }); + + expect(result.success).toBe(true); + expect(result.chapterCount).toBe(2); + expect(result.totalDuration).toBe(120000); + expect(spawnMock).toHaveBeenCalled(); + }); + + it('parses probe metadata including track numbers', async () => { + mockExecImplementation(() => ({ + stdout: JSON.stringify({ + format: { + duration: '90', + bit_rate: '256000', + tags: { track: '1/10', title: 'Chapter One' }, + }, + }), + })); + + const probe = await probeAudioFile('/tmp/chapter.mp3'); + + expect(probe.duration).toBe(90000); + expect(probe.bitrate).toBe(256); + expect(probe.trackNumber).toBe(1); + expect(probe.title).toBe('Chapter One'); + }); + + it('returns failure when ffmpeg merge fails', async () => { + const chapters = [ + { path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' }, + ]; + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + mockExecImplementation((command) => { + if (command.startsWith('ffmpeg -encoders')) { + return { stdout: 'aac encoder' }; + } + return { stdout: '' }; + }); + + spawnMock.mockReturnValue(createSpawnProcess(1, 'Error: merge failed')); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath: '/tmp/output.m4b', + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/FFmpeg merge failed/i); + }); + + it('returns failure when output validation fails', async () => { + const outputPath = '/tmp/output.m4b'; + const chapters = [ + { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, + { path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, + ]; + + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockImplementation(async (filePath: string) => { + if (filePath === outputPath) { + return { size: 2 * 1024 * 1024 }; + } + return { size: 500 * 1024 }; + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + mockExecImplementation((command) => { + if (command.startsWith('ffprobe')) { + return { + stdout: JSON.stringify({ + format: { + duration: '30', + bit_rate: '128000', + tags: {}, + }, + }), + }; + } + return { stdout: '' }; + }); + + spawnMock.mockReturnValue(createSpawnProcess(0)); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath, + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Merge validation failed/i); + }); + + it('returns failure when file integrity validation fails', async () => { + const outputPath = '/tmp/output.m4b'; + const chapters = [ + { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, + { path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, + ]; + + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + mockExecImplementation((command) => { + if (command.startsWith('ffprobe')) { + return { + stdout: JSON.stringify({ + format: { + duration: '120', + bit_rate: '128000', + tags: {}, + }, + }), + }; + } + if (command.startsWith('ffmpeg -v error')) { + return { error: new Error('decode failed') }; + } + return { stdout: '' }; + }); + + spawnMock.mockReturnValue(createSpawnProcess(0)); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath, + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/File integrity test failed/i); + }); + + it('returns failure when merged file size is too small', async () => { + const outputPath = '/tmp/output.m4b'; + const chapters = [ + { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, + { path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' }, + ]; + + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockImplementation(async (filePath: string) => { + if (filePath === outputPath) { + return { size: 200 * 1024 }; + } + return { size: 500 * 1024 }; + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + mockExecImplementation((command) => { + if (command.startsWith('ffprobe')) { + return { + stdout: JSON.stringify({ + format: { + duration: '120', + bit_rate: '128000', + tags: {}, + }, + }), + }; + } + if (command.startsWith('ffmpeg -v error')) { + return { stdout: '' }; + } + return { stdout: '' }; + }); + + spawnMock.mockReturnValue(createSpawnProcess(0)); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath, + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/File size too small/i); + }); + + it('returns failure when validation encounters an error', async () => { + const outputPath = '/tmp/output.m4b'; + const chapters = [ + { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, + ]; + + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + mockExecImplementation((command) => { + if (command.startsWith('ffprobe')) { + return { error: new Error('probe failed') }; + } + return { stdout: '' }; + }); + + spawnMock.mockReturnValue(createSpawnProcess(0)); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath, + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Validation error/i); + }); + + it('logs encoding estimates for long MP3 audiobooks', async () => { + const outputPath = '/tmp/output.m4b'; + const chapters = [ + { path: '/tmp/one.mp3', filename: 'one.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'One' }, + { path: '/tmp/two.mp3', filename: 'two.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'Two' }, + ]; + const logger = { + info: vi.fn().mockResolvedValue(undefined), + warn: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + }; + + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockImplementation(async (filePath: string) => { + if (filePath === outputPath) { + return { size: 120 * 1024 * 1024 }; + } + return { size: 500 * 1024 }; + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + mockExecImplementation((command) => { + if (command.startsWith('ffmpeg -encoders')) { + return { stdout: 'libfdk_aac' }; + } + if (command.startsWith('ffprobe')) { + return { + stdout: JSON.stringify({ + format: { + duration: '7200', + bit_rate: '128000', + tags: {}, + }, + }), + }; + } + if (command.startsWith('ffmpeg -v error')) { + return { stdout: '' }; + } + return { stdout: '' }; + }); + + spawnMock.mockReturnValue(createSpawnProcess(0)); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath, + }, logger); + + expect(result.success).toBe(true); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('long audiobook')); + }); + + it('returns failure when output file is not created', async () => { + const outputPath = '/tmp/output.m4b'; + const chapters = [ + { path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' }, + ]; + + fsMock.access.mockImplementation(async (filePath: string) => { + if (filePath === outputPath) { + throw new Error('missing'); + } + return undefined; + }); + fsMock.stat.mockResolvedValue({ size: 500 * 1024 }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + mockExecImplementation(() => ({ stdout: '' })); + spawnMock.mockReturnValue(createSpawnProcess(0)); + + const result = await mergeChapters(chapters, { + title: 'Book', + author: 'Author', + outputPath, + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/Merged file not created/i); + }); +}); diff --git a/tests/utils/cron.test.ts b/tests/utils/cron.test.ts new file mode 100644 index 0000000..753744b --- /dev/null +++ b/tests/utils/cron.test.ts @@ -0,0 +1,66 @@ +/** + * Component: Cron Utilities Tests + * Documentation: documentation/backend/services/scheduler.md + */ + +import { describe, expect, it } from 'vitest'; +import { cronToHuman, isValidCron, customScheduleToCron, cronToCustomSchedule } from '@/lib/utils/cron'; + +describe('cron utilities', () => { + it('converts known presets to human text', () => { + expect(cronToHuman('*/15 * * * *')).toBe('Every 15 minutes'); + expect(cronToHuman('0 */6 * * *')).toBe('Every 6 hours'); + expect(cronToHuman('0 * * * *')).toBe('Every hour'); + }); + + it('converts daily schedule to human text', () => { + expect(cronToHuman('30 14 * * *')).toBe('Daily at 2:30 PM'); + expect(cronToHuman('*/1 * * * *')).toBe('Every 1 minute'); + }); + + it('converts weekly and monthly schedules to human text', () => { + expect(cronToHuman('15 9 * * 1')).toBe('Weekly on Monday at 9:15 AM'); + expect(cronToHuman('0 0 15 * *')).toBe('Monthly on day 15 at 12:00 AM'); + }); + + it('returns raw cron for invalid expressions', () => { + expect(cronToHuman('bad cron')).toBe('bad cron'); + }); + + it('validates cron expressions', () => { + expect(isValidCron('*/5 * * * *')).toBe(true); + expect(isValidCron('invalid')).toBe(false); + expect(isValidCron('0 0 0 * *')).toBe(false); + expect(isValidCron('0 0 1-5 * *')).toBe(true); + expect(isValidCron('0 0 1,15 * *')).toBe(true); + expect(isValidCron('*/0 * * * *')).toBe(false); + }); + + it('converts custom schedules to cron', () => { + expect(customScheduleToCron({ type: 'minutes', interval: 10 })).toBe('*/10 * * * *'); + expect(customScheduleToCron({ type: 'hours', interval: 24 })).toBe('0 0 * * *'); + expect(customScheduleToCron({ type: 'daily', time: { hour: 9, minute: 15 } })).toBe('15 9 * * *'); + expect(customScheduleToCron({ type: 'weekly', time: { hour: 6, minute: 30 }, dayOfWeek: 2 })).toBe('30 6 * * 2'); + expect(customScheduleToCron({ type: 'monthly', time: { hour: 5, minute: 0 }, dayOfMonth: 10 })).toBe('0 5 10 * *'); + expect(customScheduleToCron({ type: 'custom', customCron: '5 4 * * *' })).toBe('5 4 * * *'); + }); + + it('parses cron into custom schedules', () => { + expect(cronToCustomSchedule('*/15 * * * *')).toEqual({ type: 'minutes', interval: 15 }); + expect(cronToCustomSchedule('0 */3 * * *')).toEqual({ type: 'hours', interval: 3 }); + expect(cronToCustomSchedule('0 7 * * *')).toEqual({ type: 'daily', time: { hour: 7, minute: 0 } }); + expect(cronToCustomSchedule('0 6 * * 2')).toEqual({ + type: 'weekly', + time: { hour: 6, minute: 0 }, + dayOfWeek: 2, + }); + expect(cronToCustomSchedule('0 2 12 * *')).toEqual({ + type: 'monthly', + time: { hour: 2, minute: 0 }, + dayOfMonth: 12, + }); + expect(cronToCustomSchedule('bad')).toEqual({ type: 'custom', customCron: 'bad' }); + }); +}); + + diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts new file mode 100644 index 0000000..cf0e35b --- /dev/null +++ b/tests/utils/file-organizer.test.ts @@ -0,0 +1,697 @@ +/** + * Component: File Organization System Tests + * Documentation: documentation/phase3/file-organization.md + */ + +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileOrganizer, getFileOrganizer } from '@/lib/utils/file-organizer'; + +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + stat: vi.fn(), + mkdir: vi.fn(), + copyFile: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), + readdir: vi.fn(), + constants: { R_OK: 4 }, +})); + +const axiosMock = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const jobLoggerMock = vi.hoisted(() => ({ + createJobLogger: vi.fn(), +})); + +const metadataMock = vi.hoisted(() => ({ + tagMultipleFiles: vi.fn(), + checkFfmpegAvailable: vi.fn(), +})); + +const chapterMock = vi.hoisted(() => ({ + detectChapterFiles: vi.fn(), + analyzeChapterFiles: vi.fn(), + mergeChapters: vi.fn(), + formatDuration: vi.fn((ms: number) => `${ms}`), + estimateOutputSize: vi.fn(), + checkDiskSpace: vi.fn(), +})); + +const loggerMock = vi.hoisted(() => ({ + RMABLogger: { + create: vi.fn(() => ({ + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), + }, +})); + +const configState = vi.hoisted(() => ({ + values: new Map(), +})); + +const prismaMock = vi.hoisted(() => ({ + configuration: { + findUnique: vi.fn(async ({ where: { key } }: { where: { key: string } }) => { + const value = configState.values.get(key); + return value !== undefined ? { value } : null; + }), + }, +})); + +const ebookMock = vi.hoisted(() => ({ + downloadEbook: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ + default: fsMock, + ...fsMock, +})); + +vi.mock('axios', () => ({ + default: axiosMock, + ...axiosMock, +})); + +vi.mock('@/lib/utils/job-logger', () => jobLoggerMock); +vi.mock('@/lib/utils/metadata-tagger', () => metadataMock); +vi.mock('@/lib/utils/chapter-merger', () => chapterMock); +vi.mock('@/lib/utils/logger', () => loggerMock); +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); +vi.mock('@/lib/services/ebook-scraper', () => ebookMock); + +describe('file organizer', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + configState.values.clear(); + process.env = { ...originalEnv }; + }); + + it('organizes a single file and copies cached cover art', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + fsMock.stat.mockResolvedValue({ isFile: () => true }); + fsMock.access.mockImplementation(async (filePath: string) => { + if (filePath === '/downloads/book.m4b') return undefined; + if (filePath === '/app/cache/thumbnails/cover.jpg') return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + + const logger = { + info: vi.fn().mockResolvedValue(undefined), + warn: vi.fn().mockResolvedValue(undefined), + error: vi.fn().mockResolvedValue(undefined), + }; + jobLoggerMock.createJobLogger.mockReturnValue(logger); + + const organizer = new FileOrganizer('/media', '/tmp'); + const result = await organizer.organize( + '/downloads/book.m4b', + { + title: 'Book: Title', + author: 'Author/Name', + year: 2020, + asin: 'ASIN123', + coverArtUrl: '/api/cache/thumbnails/cover.jpg', + }, + { jobId: 'job-1', context: 'organize' } + ); + + const expectedDir = path.join('/media', 'AuthorName', 'Book Title (2020) ASIN123'); + const expectedAudio = path.join(expectedDir, 'book.m4b'); + + expect(result.success).toBe(true); + expect(result.targetPath).toBe(expectedDir); + expect(result.audioFiles).toEqual([expectedAudio]); + expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg')); + expect(result.filesMovedCount).toBe(1); + expect(jobLoggerMock.createJobLogger).toHaveBeenCalledWith('job-1', 'organize'); + expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled(); + }); + + it('returns errors when no audiobook files are found', async () => { + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: [], + coverFile: undefined, + isFile: false, + }); + + const result = await organizer.organize('/downloads/empty', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(false); + expect(result.errors).toContain('No audiobook files found in download'); + }); + + it('falls back when chapter merge fails and continues organizing', async () => { + configState.values.set('chapter_merging_enabled', 'true'); + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + chapterMock.detectChapterFiles.mockResolvedValue(true); + chapterMock.estimateOutputSize.mockResolvedValue(100); + chapterMock.checkDiskSpace.mockResolvedValue(1000); + chapterMock.analyzeChapterFiles.mockResolvedValue([ + { path: '/downloads/book/disc1.mp3', filename: 'disc1.mp3', duration: 1000, chapterTitle: 'One' }, + ]); + chapterMock.mergeChapters.mockResolvedValue({ success: false, error: 'merge failed' }); + + const downloadRoot = path.normalize(path.join('/downloads', 'book')); + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath).startsWith(downloadRoot)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['disc1.mp3', 'disc2.mp3'], + coverFile: undefined, + isFile: false, + }); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.filesMovedCount).toBe(2); + expect(result.errors.join(' ')).toContain('Chapter merge failed'); + expect(chapterMock.mergeChapters).toHaveBeenCalled(); + }); + + it('uses tagged files when metadata tagging succeeds', async () => { + configState.values.set('metadata_tagging_enabled', 'true'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + metadataMock.checkFfmpegAvailable.mockResolvedValue(true); + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + metadataMock.tagMultipleFiles.mockResolvedValue([ + { + success: true, + filePath: sourcePath, + taggedFilePath: '/tmp/tagged.m4b', + }, + ]); + + const downloadRoot = path.normalize(path.join('/downloads', 'book')); + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize('/tmp/tagged.m4b')) return undefined; + if (path.normalize(filePath).startsWith(downloadRoot)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + const expectedDir = path.join('/media', 'Author', 'Book'); + expect(result.success).toBe(true); + expect(result.targetPath).toBe(expectedDir); + expect(fsMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b')); + expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b'); + }); + + it('skips metadata tagging when ffmpeg is unavailable', async () => { + configState.values.set('metadata_tagging_enabled', 'true'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + metadataMock.checkFfmpegAvailable.mockResolvedValue(false); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const expectedDir = path.join('/media', 'Author', 'Book'); + const targetFile = path.join(expectedDir, 'book.m4b'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available'); + expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled(); + expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + }); + + it('downloads remote cover art and ebook sidecar when enabled', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'true'); + configState.values.set('ebook_sidecar_preferred_format', 'epub'); + configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example'); + configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr'); + + ebookMock.downloadEbook.mockResolvedValue({ + success: true, + filePath: '/media/Author/Book/book.epub', + }); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const expectedDir = path.join('/media', 'Author', 'Book ASIN123'); + const targetFile = path.join(expectedDir, 'book.m4b'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + fsMock.writeFile.mockResolvedValue(undefined); + + axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') }); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + asin: 'ASIN123', + coverArtUrl: 'https://images.example/cover.jpg', + }); + + expect(result.success).toBe(true); + expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg')); + expect(axiosMock.get).toHaveBeenCalledWith( + 'https://images.example/cover.jpg', + expect.objectContaining({ responseType: 'arraybuffer' }) + ); + expect(ebookMock.downloadEbook).toHaveBeenCalledWith( + 'ASIN123', + 'Book', + 'Author', + expectedDir, + 'epub', + 'https://ebooks.example', + undefined, + 'http://flaresolverr' + ); + expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + expect(result.filesMovedCount).toBe(2); + }); + + it('records an error when cover art download fails', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const expectedDir = path.join('/media', 'Author', 'Book'); + const targetFile = path.join(expectedDir, 'book.m4b'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + axiosMock.get.mockRejectedValue(new Error('cover failed')); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + coverArtUrl: 'https://images.example/cover.jpg', + }); + + expect(result.success).toBe(true); + expect(result.errors.join(' ')).toContain('Failed to download cover art'); + expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + }); + + it('continues when chapter analysis returns no valid chapters', async () => { + configState.values.set('chapter_merging_enabled', 'true'); + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + chapterMock.detectChapterFiles.mockResolvedValue(true); + chapterMock.estimateOutputSize.mockResolvedValue(100); + chapterMock.checkDiskSpace.mockResolvedValue(1000); + chapterMock.analyzeChapterFiles.mockResolvedValue([]); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['disc1.mp3', 'disc2.mp3'], + coverFile: undefined, + isFile: false, + }); + + const sourceRoot = path.normalize('/downloads/book'); + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath).startsWith(sourceRoot)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.filesMovedCount).toBe(2); + expect(chapterMock.mergeChapters).not.toHaveBeenCalled(); + }); + + it('records errors when some metadata tagging operations fail', async () => { + configState.values.set('metadata_tagging_enabled', 'true'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + metadataMock.checkFfmpegAvailable.mockResolvedValue(true); + metadataMock.tagMultipleFiles.mockResolvedValue([ + { success: true, filePath: '/downloads/book/one.m4b', taggedFilePath: '/tmp/one-tagged.m4b' }, + { success: false, filePath: '/downloads/book/two.m4b', error: 'bad tags' }, + ]); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['one.m4b', 'two.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourceRoot = path.normalize('/downloads/book'); + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize('/tmp/one-tagged.m4b')) return undefined; + if (path.normalize(filePath).startsWith(sourceRoot)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata'); + }); + + it('records ebook sidecar errors when download throws', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'true'); + + ebookMock.downloadEbook.mockRejectedValue(new Error('ebook down')); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.errors).toContain('E-book sidecar failed'); + }); + + it('finds audio files and cover art in nested folders', async () => { + const organizer = new FileOrganizer('/media', '/tmp'); + + fsMock.stat.mockResolvedValue({ isFile: () => false }); + const subDir = path.join('/downloads', 'sub'); + fsMock.readdir.mockImplementation(async (dir: string) => { + if (dir === '/downloads') { + return [ + { name: 'disc1.mp3', isDirectory: () => false }, + { name: 'sub', isDirectory: () => true }, + ]; + } + if (dir === subDir) { + return [ + { name: 'disc2.mp3', isDirectory: () => false }, + { name: 'cover.jpg', isDirectory: () => false }, + ]; + } + return []; + }); + + const result = await (organizer as any).findAudiobookFiles('/downloads'); + + expect(result.audioFiles).toEqual([ + 'disc1.mp3', + path.join('sub', 'disc2.mp3'), + ]); + expect(result.coverFile).toBe(path.join('sub', 'cover.jpg')); + expect(result.isFile).toBe(false); + }); + + it('returns no audio files for unsupported single files', async () => { + const organizer = new FileOrganizer('/media', '/tmp'); + fsMock.stat.mockResolvedValue({ isFile: () => true }); + + const result = await (organizer as any).findAudiobookFiles('/downloads/readme.txt'); + + expect(result.audioFiles).toEqual([]); + expect(result.isFile).toBe(true); + }); + + it('adds errors when source audio files are missing', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) { + throw new Error('missing'); + } + return undefined; + }); + fsMock.mkdir.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.errors.join(' ')).toContain('Source file not found'); + expect(fsMock.copyFile).not.toHaveBeenCalled(); + }); + + it('skips copying when target files already exist', async () => { + configState.values.set('metadata_tagging_enabled', 'false'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + const targetDir = path.join('/media', 'Author', 'Book'); + const targetPath = path.join(targetDir, 'book.m4b'); + + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + if (path.normalize(filePath) === path.normalize(targetPath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.audioFiles).toEqual([targetPath]); + expect(result.filesMovedCount).toBe(0); + expect(fsMock.copyFile).not.toHaveBeenCalled(); + }); + + it('continues when metadata tagging throws', async () => { + configState.values.set('metadata_tagging_enabled', 'true'); + configState.values.set('ebook_sidecar_enabled', 'false'); + + metadataMock.checkFfmpegAvailable.mockRejectedValue(new Error('ffmpeg error')); + + const organizer = new FileOrganizer('/media', '/tmp'); + (organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({ + audioFiles: ['book.m4b'], + coverFile: undefined, + isFile: false, + }); + + const sourcePath = path.join('/downloads', 'book', 'book.m4b'); + fsMock.access.mockImplementation(async (filePath: string) => { + if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined; + throw new Error('missing'); + }); + fsMock.mkdir.mockResolvedValue(undefined); + fsMock.copyFile.mockResolvedValue(undefined); + fsMock.chmod.mockResolvedValue(undefined); + + const result = await organizer.organize('/downloads/book', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(true); + expect(result.errors.join(' ')).toContain('Metadata tagging failed'); + expect(fsMock.copyFile).toHaveBeenCalled(); + }); + + it('validates paths and reports multiple issues', async () => { + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockResolvedValue({ isDirectory: () => false }); + fsMock.writeFile.mockRejectedValue(new Error('not writable')); + + const organizer = new FileOrganizer('/media', '/tmp'); + const result = await organizer.validate('/media'); + + expect(result.isValid).toBe(false); + expect(result.issues).toContain('Path is not a directory'); + expect(result.issues).toContain('Directory is not writable'); + }); + + it('returns validation errors when path is missing', async () => { + fsMock.access.mockRejectedValue(new Error('missing')); + + const organizer = new FileOrganizer('/media', '/tmp'); + const result = await organizer.validate('/missing'); + + expect(result.isValid).toBe(false); + expect(result.issues.join(' ')).toContain('Path does not exist'); + }); + + it('throws when the download directory cannot be read', async () => { + fsMock.stat.mockRejectedValue(new Error('bad path')); + + const organizer = new FileOrganizer('/media', '/tmp'); + await expect((organizer as any).findAudiobookFiles('/downloads/bad')).rejects.toThrow('bad path'); + }); + + it('returns an empty list when walkDirectory fails', async () => { + fsMock.readdir.mockRejectedValue(new Error('no perms')); + + const organizer = new FileOrganizer('/media', '/tmp'); + const files = await (organizer as any).walkDirectory('/downloads'); + + expect(files).toEqual([]); + }); + + it('cleans up download directories safely', async () => { + fsMock.rm.mockRejectedValue(new Error('rm failed')); + + const organizer = new FileOrganizer('/media', '/tmp'); + await expect(organizer.cleanup('/downloads/book')).resolves.toBeUndefined(); + expect(fsMock.rm).toHaveBeenCalledWith('/downloads/book', { recursive: true, force: true }); + }); + + it('cleans up download directories on success', async () => { + fsMock.rm.mockResolvedValue(undefined); + + const organizer = new FileOrganizer('/media', '/tmp'); + await expect(organizer.cleanup('/downloads/book')).resolves.toBeUndefined(); + expect(fsMock.rm).toHaveBeenCalledWith('/downloads/book', { recursive: true, force: true }); + }); + + it('validates writable directories without issues', async () => { + fsMock.access.mockResolvedValue(undefined); + fsMock.stat.mockResolvedValue({ isDirectory: () => true }); + fsMock.writeFile.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + + const organizer = new FileOrganizer('/media', '/tmp'); + const result = await organizer.validate('/media'); + + expect(result.isValid).toBe(true); + expect(result.issues).toEqual([]); + expect(fsMock.unlink).toHaveBeenCalledWith(path.join('/media', '.test-write')); + }); + + it('builds organizer settings from configuration', async () => { + configState.values.set('media_dir', '/media/custom'); + process.env.TEMP_DIR = '/tmp/custom'; + + const organizer = await getFileOrganizer(); + + expect((organizer as any).mediaDir).toBe('/media/custom'); + expect((organizer as any).tempDir).toBe('/tmp/custom'); + }); +}); diff --git a/tests/utils/job-logger.test.ts b/tests/utils/job-logger.test.ts new file mode 100644 index 0000000..eb59925 --- /dev/null +++ b/tests/utils/job-logger.test.ts @@ -0,0 +1,47 @@ +/** + * Component: Job Logger Utility Tests + * Documentation: documentation/backend/services/jobs.md + */ + +import { describe, expect, it, vi } from 'vitest'; + +const infoMock = vi.fn(); +const warnMock = vi.fn(); +const errorMock = vi.fn(); +const forJobMock = vi.fn(() => ({ + info: infoMock, + warn: warnMock, + error: errorMock, +})); + +vi.mock('@/lib/utils/logger', () => ({ + RMABLogger: { + forJob: forJobMock, + }, +})); + +describe('JobLogger', () => { + it('logs info, warn, and error messages via RMABLogger', async () => { + const { JobLogger } = await import('@/lib/utils/job-logger'); + const logger = new JobLogger('job-1', 'Context'); + + await logger.info('info message', { foo: 'bar' }); + await logger.warn('warn message'); + await logger.error('error message', { error: 'boom' }); + + expect(forJobMock).toHaveBeenCalledWith('job-1', 'Context'); + expect(infoMock).toHaveBeenCalledWith('info message', { foo: 'bar' }); + expect(warnMock).toHaveBeenCalledWith('warn message', undefined); + expect(errorMock).toHaveBeenCalledWith('error message', { error: 'boom' }); + }); + + it('creates a job logger via helper', async () => { + const { createJobLogger } = await import('@/lib/utils/job-logger'); + const logger = createJobLogger('job-2', 'Context2'); + + await logger.info('message'); + + expect(forJobMock).toHaveBeenCalledWith('job-2', 'Context2'); + expect(infoMock).toHaveBeenCalledWith('message', undefined); + }); +}); diff --git a/tests/utils/jwt-client.test.ts b/tests/utils/jwt-client.test.ts new file mode 100644 index 0000000..272a259 --- /dev/null +++ b/tests/utils/jwt-client.test.ts @@ -0,0 +1,107 @@ +/** + * Component: Client-Side JWT Utilities Tests + * Documentation: documentation/frontend/routing-auth.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const loggerState = vi.hoisted(() => ({ + error: vi.fn(), + create: vi.fn(), +})); + +vi.mock('@/lib/utils/logger', () => ({ + RMABLogger: { + create: loggerState.create, + }, +})); + +const base64Url = (value: unknown) => + Buffer.from(JSON.stringify(value)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + +const createToken = (payload: Record) => { + const header = base64Url({ alg: 'HS256', typ: 'JWT' }); + const body = base64Url(payload); + return `${header}.${body}.signature`; +}; + +describe('jwt client utilities', () => { + const originalAtob = globalThis.atob; + + beforeEach(() => { + vi.resetModules(); + loggerState.error.mockClear(); + loggerState.create.mockReturnValue({ error: loggerState.error }); + + globalThis.atob = (input: string) => Buffer.from(input, 'base64').toString('binary'); + }); + + it('decodes a valid JWT payload', async () => { + const { decodeJWT } = await import('@/lib/utils/jwt-client'); + + const token = createToken({ sub: 'user', exp: 2000, role: 'user' }); + const decoded = decodeJWT(token); + + expect(decoded?.sub).toBe('user'); + expect(decoded?.exp).toBe(2000); + }); + + it('returns null for invalid tokens', async () => { + const { decodeJWT } = await import('@/lib/utils/jwt-client'); + + expect(decodeJWT('not-a-token')).toBeNull(); + }); + + it('logs an error when decoding fails', async () => { + const { decodeJWT } = await import('@/lib/utils/jwt-client'); + + const decoded = decodeJWT('header.badbase64.signature'); + + expect(decoded).toBeNull(); + expect(loggerState.error).toHaveBeenCalled(); + }); + + it('checks token expiry correctly', async () => { + const { isTokenExpired } = await import('@/lib/utils/jwt-client'); + const now = 1700000000; + + vi.spyOn(Date, 'now').mockReturnValue(now * 1000); + + const fresh = createToken({ exp: now + 60 }); + const expired = createToken({ exp: now - 60 }); + + expect(isTokenExpired(fresh)).toBe(false); + expect(isTokenExpired(expired)).toBe(true); + expect(isTokenExpired('invalid')).toBe(true); + }); + + it('returns expiry and refresh windows', async () => { + const { getRefreshTimeMs, getTokenExpiryMs } = await import('@/lib/utils/jwt-client'); + const now = 1700000000; + + vi.spyOn(Date, 'now').mockReturnValue(now * 1000); + + const token = createToken({ exp: now + 600 }); + const expiryMs = getTokenExpiryMs(token); + const refreshMs = getRefreshTimeMs(token); + + expect(expiryMs).toBe(600 * 1000); + expect(refreshMs).toBe(300 * 1000); + + const shortToken = createToken({ exp: now + 60 }); + expect(getRefreshTimeMs(shortToken)).toBe(0); + expect(getTokenExpiryMs('invalid')).toBeNull(); + }); + + afterEach(() => { + if (originalAtob) { + globalThis.atob = originalAtob; + } else { + delete (globalThis as any).atob; + } + }); +}); diff --git a/tests/utils/jwt.test.ts b/tests/utils/jwt.test.ts new file mode 100644 index 0000000..91cdea8 --- /dev/null +++ b/tests/utils/jwt.test.ts @@ -0,0 +1,70 @@ +/** + * Component: JWT Utilities Tests + * Documentation: documentation/backend/services/auth.md + */ + +import { describe, expect, it } from 'vitest'; +import jwt from 'jsonwebtoken'; +import { + decodeToken, + generateAccessToken, + generateRefreshToken, + verifyAccessToken, + verifyRefreshToken, +} from '@/lib/utils/jwt'; + +describe('JWT utilities', () => { + it('generates and verifies access tokens', () => { + const token = generateAccessToken({ + sub: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'admin', + }); + + const payload = verifyAccessToken(token); + + expect(payload?.sub).toBe('user-1'); + expect(payload?.role).toBe('admin'); + }); + + it('returns null for invalid access tokens', () => { + const payload = verifyAccessToken('bad-token'); + + expect(payload).toBeNull(); + }); + + it('generates and verifies refresh tokens', () => { + const token = generateRefreshToken('user-2'); + const payload = verifyRefreshToken(token); + + expect(payload?.sub).toBe('user-2'); + expect(payload?.type).toBe('refresh'); + }); + + it('returns null when refresh token type does not match', () => { + const invalid = jwt.sign( + { sub: 'user-3', type: 'access' }, + 'change-this-to-another-random-secret-key', + { expiresIn: '7d' } + ); + + const payload = verifyRefreshToken(invalid); + + expect(payload).toBeNull(); + }); + + it('decodes tokens without verification', () => { + const token = generateAccessToken({ + sub: 'user-4', + plexId: 'plex-4', + username: 'user', + role: 'user', + }); + + const decoded = decodeToken(token) as { sub?: string } | null; + + expect(decoded?.sub).toBe('user-4'); + expect(decodeToken('not-a-jwt')).toBeNull(); + }); +}); diff --git a/tests/utils/metadata-tagger.test.ts b/tests/utils/metadata-tagger.test.ts new file mode 100644 index 0000000..de5c5c5 --- /dev/null +++ b/tests/utils/metadata-tagger.test.ts @@ -0,0 +1,116 @@ +/** + * Component: Metadata Tagging Utility Tests + * Documentation: documentation/phase3/file-organization.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkFfmpegAvailable, tagAudioFileMetadata, tagMultipleFiles } from '@/lib/utils/metadata-tagger'; + +const execMock = vi.hoisted(() => vi.fn()); +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + unlink: vi.fn(), +})); + +vi.mock('child_process', () => ({ + exec: execMock, +})); + +vi.mock('fs/promises', () => ({ + default: fsMock, + ...fsMock, +})); + +function mockExecSuccess(stdout = 'ok') { + execMock.mockImplementation((command: string, options: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + cb(null, stdout, ''); + }); +} + +function mockExecFailure(message = 'ffmpeg error') { + execMock.mockImplementation((command: string, options: any, callback?: any) => { + const cb = typeof options === 'function' ? options : callback; + cb(new Error(message), '', ''); + }); +} + +describe('metadata tagger', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an error for unsupported file formats', async () => { + fsMock.access.mockResolvedValue(undefined); + + const result = await tagAudioFileMetadata('/tmp/book.wav', { + title: 'Book', + author: 'Author', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Unsupported file format'); + expect(execMock).not.toHaveBeenCalled(); + }); + + it('tags an m4b file with metadata', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecSuccess('done'); + + const result = await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: 'Author', + narrator: 'Narrator', + year: 2020, + asin: 'ASIN123', + }); + + expect(result.success).toBe(true); + expect(result.taggedFilePath).toBe('/tmp/book.m4b.tmp'); + + const command = execMock.mock.calls[0][0] as string; + expect(command).toContain('-metadata title="Book"'); + expect(command).toContain('-metadata album_artist="Author"'); + expect(command).toContain('-metadata composer="Narrator"'); + expect(command).toContain('-metadata date="2020"'); + expect(command).toContain('-metadata ----:com.apple.iTunes:ASIN="ASIN123"'); + expect(command).toContain('-f mp4'); + }); + + it('cleans up temp files and returns errors when ffmpeg fails', async () => { + fsMock.access.mockResolvedValue(undefined); + fsMock.unlink.mockResolvedValue(undefined); + mockExecFailure('exec failed'); + + const result = await tagAudioFileMetadata('/tmp/book.mp3', { + title: 'Book', + author: 'Author', + asin: 'ASIN123', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('ffmpeg failed'); + expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/book.mp3.tmp'); + }); + + it('tags multiple files in sequence', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecSuccess('done'); + + const results = await tagMultipleFiles(['/tmp/one.m4a', '/tmp/two.m4a'], { + title: 'Book', + author: 'Author', + }); + + expect(results).toHaveLength(2); + expect(results.every((result) => result.success)).toBe(true); + }); + + it('checks ffmpeg availability', async () => { + mockExecSuccess('ffmpeg version'); + await expect(checkFfmpegAvailable()).resolves.toBe(true); + + mockExecFailure('not installed'); + await expect(checkFfmpegAvailable()).resolves.toBe(false); + }); +}); diff --git a/tests/utils/path-mapper.test.ts b/tests/utils/path-mapper.test.ts new file mode 100644 index 0000000..d467181 --- /dev/null +++ b/tests/utils/path-mapper.test.ts @@ -0,0 +1,50 @@ +/** + * Component: Path Mapper Tests + * Documentation: documentation/phase3/qbittorrent.md + */ + +import { describe, expect, it } from 'vitest'; +import { PathMapper } from '@/lib/utils/path-mapper'; + +describe('PathMapper', () => { + it('returns original path when mapping is disabled', () => { + const result = PathMapper.transform('/remote/path/book', { + enabled: false, + remotePath: '/remote/path', + localPath: '/local/path', + }); + + expect(result).toBe('/remote/path/book'); + }); + + it('transforms remote path to local path when enabled', () => { + const result = PathMapper.transform('/remote/mnt/d/done/Book', { + enabled: true, + remotePath: '/remote/mnt/d/done', + localPath: '/downloads', + }); + + expect(result.replace(/\\/g, '/')).toBe('/downloads/Book'); + }); + + it('returns original path when remote prefix does not match', () => { + const result = PathMapper.transform('/other/path/book', { + enabled: true, + remotePath: '/remote/path', + localPath: '/local/path', + }); + + expect(result).toBe('/other/path/book'); + }); + + it('validates mapping configuration when enabled', () => { + expect(() => + PathMapper.validate({ enabled: true, remotePath: '', localPath: '/local' }) + ).toThrow('Remote path cannot be empty'); + expect(() => + PathMapper.validate({ enabled: true, remotePath: '/remote', localPath: '' }) + ).toThrow('Local path cannot be empty'); + }); +}); + + diff --git a/tests/utils/ranking-algorithm.test.ts b/tests/utils/ranking-algorithm.test.ts new file mode 100644 index 0000000..37d7755 --- /dev/null +++ b/tests/utils/ranking-algorithm.test.ts @@ -0,0 +1,154 @@ +/** + * Component: Intelligent Ranking Algorithm Tests + * Documentation: documentation/phase3/ranking-algorithm.md + */ + +import { describe, expect, it } from 'vitest'; +import { RankingAlgorithm, rankTorrents } from '@/lib/utils/ranking-algorithm'; + +const MB = 1024 * 1024; + +describe('ranking-algorithm', () => { + const baseTorrent = { + indexer: 'IndexerA', + title: 'Great Book - Author Name', + size: 30 * MB, + seeders: 10, + leechers: 1, + publishDate: new Date('2024-01-01T00:00:00Z'), + downloadUrl: 'magnet:?xt=urn:btih:abc', + guid: 'guid-1', + }; + + it('filters out results below 20 MB', () => { + const small = { ...baseTorrent, guid: 'small', size: 10 * MB }; + const big = { ...baseTorrent, guid: 'big', size: 25 * MB }; + + const ranked = rankTorrents( + [small, big], + { title: 'Great Book', author: 'Author Name' } + ); + + expect(ranked).toHaveLength(1); + expect(ranked[0].guid).toBe('big'); + }); + + it('prefers strong title/author matches over weaker ones', () => { + const good = { ...baseTorrent, guid: 'good', title: 'Great Book - Author Name' }; + const bad = { ...baseTorrent, guid: 'bad', title: 'Different Title - Other Author' }; + + const ranked = rankTorrents( + [bad, good], + { title: 'Great Book', author: 'Author Name' } + ); + + expect(ranked[0].guid).toBe('good'); + }); + + it('treats undefined seeders as full availability score (usenet)', () => { + const algorithm = new RankingAlgorithm(); + const torrent = { ...baseTorrent, seeders: undefined }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Great Book', + author: 'Author Name', + }); + + expect(breakdown.seederScore).toBe(15); + }); + + it('assigns full size score for >= 1.0 MB/min', () => { + const algorithm = new RankingAlgorithm(); + const torrent = { ...baseTorrent, size: 150 * MB }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'Great Book', + author: 'Author Name', + durationMinutes: 100, + }); + + expect(breakdown.sizeScore).toBe(15); + }); + + it('applies word coverage filter for partial title matches', () => { + const algorithm = new RankingAlgorithm(); + const torrent = { ...baseTorrent, title: 'The Wild Robot' }; + + const breakdown = algorithm.getScoreBreakdown(torrent, { + title: 'The Wild Robot on the Island', + author: 'Peter Brown', + }); + + expect(breakdown.matchScore).toBe(0); + }); + + it('adds seeder availability notes and weak match notes', () => { + const algorithm = new RankingAlgorithm(); + const baseBreakdown = { + formatScore: 0, + sizeScore: 0, + seederScore: 0, + matchScore: 30, + totalScore: 30, + notes: [], + }; + + const noSeeders = (algorithm as any).generateNotes( + { ...baseTorrent, seeders: 0 }, + baseBreakdown, + 120 + ); + expect(noSeeders.some((note: string) => note.includes('No seeders'))).toBe(true); + expect(noSeeders.some((note: string) => note.includes('Weak title/author match'))).toBe(true); + + const lowSeeders = (algorithm as any).generateNotes( + { ...baseTorrent, seeders: 3 }, + baseBreakdown, + 120 + ); + expect(lowSeeders.some((note: string) => note.includes('Low seeders'))).toBe(true); + + const highSeeders = (algorithm as any).generateNotes( + { ...baseTorrent, seeders: 50 }, + baseBreakdown, + 120 + ); + expect(highSeeders.some((note: string) => note.includes('Excellent availability'))).toBe(true); + }); + + it('adds format and size quality notes for MP3 files', () => { + const algorithm = new RankingAlgorithm(); + const breakdown = { + formatScore: 0, + sizeScore: 0, + seederScore: 0, + matchScore: 50, + totalScore: 50, + notes: [], + }; + + const highQuality = (algorithm as any).generateNotes( + { ...baseTorrent, format: 'MP3', size: 70 * MB }, + breakdown, + 60 + ); + expect(highQuality.some((note: string) => note.includes('Acceptable format'))).toBe(true); + expect(highQuality.some((note: string) => note.includes('High quality'))).toBe(true); + + const standardQuality = (algorithm as any).generateNotes( + { ...baseTorrent, format: 'MP3', size: 30 * MB }, + breakdown, + 60 + ); + expect(standardQuality.some((note: string) => note.includes('Standard quality'))).toBe(true); + + const lowQuality = (algorithm as any).generateNotes( + { ...baseTorrent, format: 'MP3', size: 20 * MB }, + breakdown, + 60 + ); + expect(lowQuality.some((note: string) => note.includes('Low quality'))).toBe(true); + }); +}); + + diff --git a/tests/utils/torrent-categories.test.ts b/tests/utils/torrent-categories.test.ts new file mode 100644 index 0000000..44ec5e8 --- /dev/null +++ b/tests/utils/torrent-categories.test.ts @@ -0,0 +1,42 @@ +/** + * Component: Torrent Category Utils Tests + * Documentation: documentation/phase3/prowlarr.md + */ + +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_CATEGORIES, + TORRENT_CATEGORIES, + areAllChildrenSelected, + getChildIds, + getParentId, + isParentCategory, +} from '@/lib/utils/torrent-categories'; + +describe('torrent categories', () => { + it('returns child ids for parent categories', () => { + expect(getChildIds(3000)).toContain(3030); + expect(getChildIds(8000)).toEqual([]); + }); + + it('returns parent id for child categories', () => { + expect(getParentId(3030)).toBe(3000); + expect(getParentId(9999)).toBeNull(); + }); + + it('checks if all children are selected', () => { + const childIds = getChildIds(3000); + expect(areAllChildrenSelected(3000, childIds)).toBe(true); + expect(areAllChildrenSelected(3000, [])).toBe(false); + }); + + it('detects parent categories', () => { + expect(isParentCategory(3000)).toBe(true); + expect(isParentCategory(3030)).toBe(false); + }); + + it('keeps default categories stable', () => { + expect(DEFAULT_CATEGORIES).toEqual([3030]); + expect(TORRENT_CATEGORIES.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/utils/url.test.ts b/tests/utils/url.test.ts new file mode 100644 index 0000000..0a16b40 --- /dev/null +++ b/tests/utils/url.test.ts @@ -0,0 +1,67 @@ +/** + * Component: URL Utilities Tests + * Documentation: documentation/backend/services/environment.md + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getBaseUrl, getCallbackUrl } from '@/lib/utils/url'; + +const envBackup = { ...process.env }; + +describe('URL utilities', () => { + beforeEach(() => { + process.env = { ...envBackup }; + }); + + afterEach(() => { + process.env = { ...envBackup }; + }); + + it('prefers PUBLIC_URL and trims trailing slashes', () => { + process.env.PUBLIC_URL = 'https://example.com/'; + process.env.NEXTAUTH_URL = 'https://next.example.com'; + process.env.BASE_URL = 'https://base.example.com'; + + const url = getBaseUrl(); + + expect(url).toBe('https://example.com'); + }); + + it('falls back to NEXTAUTH_URL when PUBLIC_URL is not set', () => { + delete process.env.PUBLIC_URL; + process.env.NEXTAUTH_URL = 'https://next.example.com/'; + + const url = getBaseUrl(); + + expect(url).toBe('https://next.example.com'); + }); + + it('uses BASE_URL and keeps invalid scheme values', () => { + delete process.env.PUBLIC_URL; + delete process.env.NEXTAUTH_URL; + process.env.BASE_URL = 'example.com/'; + + const url = getBaseUrl(); + + expect(url).toBe('example.com'); + }); + + it('defaults to localhost in production when no env vars are set', () => { + delete process.env.PUBLIC_URL; + delete process.env.NEXTAUTH_URL; + delete process.env.BASE_URL; + process.env.NODE_ENV = 'production'; + + const url = getBaseUrl(); + + expect(url).toBe('http://localhost:3030'); + }); + + it('builds callback URLs with normalized paths', () => { + process.env.PUBLIC_URL = 'https://example.com'; + + const url = getCallbackUrl('api/auth/oidc/callback'); + + expect(url).toBe('https://example.com/api/auth/oidc/callback'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 9c5c0d3..1e9a4ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "tests"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..24114a6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,38 @@ +/** + * Component: Test Runner Configuration + * Documentation: documentation/README.md + */ + +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import path from 'path'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, + test: { + environment: 'node', + globals: true, + setupFiles: ['tests/setup.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], + clearMocks: true, + mockReset: true, + restoreMocks: true, + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/generated/**', + 'src/**/*.d.ts', + 'src/**/types/**', + 'src/**/types.ts', + 'src/**/index.ts', + ], + }, + }, +});