mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 12:20:09 +00:00
Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Generated
+2381
-1
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Settings | null>(null);
|
||||
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [validated, setValidated] = useState<ValidationState>({
|
||||
plex: false,
|
||||
audiobookshelf: false,
|
||||
oidc: false,
|
||||
registration: false,
|
||||
prowlarr: false,
|
||||
download: false,
|
||||
paths: false,
|
||||
});
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||
|
||||
/**
|
||||
* 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<Settings> | ((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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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: '📚' },
|
||||
];
|
||||
@@ -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';
|
||||
+174
-2820
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* OIDC Settings Section */}
|
||||
<OIDCSection
|
||||
settings={settings.oidc}
|
||||
onChange={handleOIDCChange}
|
||||
onTest={handleOIDCTest}
|
||||
testing={testing}
|
||||
testResult={oidcTestResult}
|
||||
onValidationChange={() => onValidationChange('oidc', true)}
|
||||
/>
|
||||
|
||||
{/* Registration Settings Section */}
|
||||
<RegistrationSection
|
||||
settings={settings.registration}
|
||||
onChange={handleRegistrationChange}
|
||||
/>
|
||||
|
||||
{/* Warning: No auth methods enabled AND no local users exist */}
|
||||
{showNoAuthWarning && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-red-800 dark:text-red-200">
|
||||
No Authentication Methods Available
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info: Registration disabled but local users can still log in */}
|
||||
{showRegistrationDisabledInfo && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-800 dark:text-blue-200">
|
||||
Manual Registration Disabled
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
New user registration is disabled. Existing local users can still log in with their credentials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Users Section */}
|
||||
{showPendingUsers && (
|
||||
<PendingUsersTable
|
||||
pendingUsers={pendingUsers}
|
||||
loading={loadingPendingUsers}
|
||||
onApprove={approveUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
OIDC Authentication
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure OpenID Connect (OIDC) authentication for single sign-on with Authentik, Keycloak, or other providers.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable OIDC Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="oidc-enabled"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, enabled: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="oidc-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable OIDC Authentication
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Allow users to log in using an external OIDC provider
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.enabled && (
|
||||
<>
|
||||
{/* Provider Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Provider Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.providerName}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, providerName: e.target.value });
|
||||
}}
|
||||
placeholder="Authentik"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Display name for the login button
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Issuer URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Issuer URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.issuerUrl}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, issuerUrl: e.target.value });
|
||||
}}
|
||||
placeholder="https://auth.example.com/application/o/readmeabook/"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
OIDC provider's issuer URL (must support .well-known/openid-configuration)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client ID
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.clientId}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, clientId: e.target.value });
|
||||
}}
|
||||
placeholder="readmeabook-client"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Secret
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.clientSecret}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, clientSecret: e.target.value });
|
||||
}}
|
||||
placeholder="Enter client secret"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Connection Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.issuerUrl || !settings.clientId || !settings.clientSecret}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test OIDC Configuration
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Access Control
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Control who can log in to your application. This is separate from admin permissions.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Access Control Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Access Control Method
|
||||
</label>
|
||||
<select
|
||||
value={settings.accessControlMethod}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, accessControlMethod: e.target.value });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="open">Open Access (anyone can log in)</option>
|
||||
<option value="group_claim">Group/Claim Based</option>
|
||||
<option value="allowed_list">Allowed List (emails/usernames)</option>
|
||||
<option value="admin_approval">Admin Approval Required</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{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'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Group/Claim Based Controls */}
|
||||
{settings.accessControlMethod === 'group_claim' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Group Claim Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.accessGroupClaim}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, accessGroupClaim: e.target.value });
|
||||
}}
|
||||
placeholder="groups"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
The OIDC claim field that contains group membership (usually "groups" or "roles")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Required Group
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.accessGroupValue}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, accessGroupValue: e.target.value });
|
||||
}}
|
||||
placeholder="readmeabook-users"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Users must be in this group to access the application
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed List Controls */}
|
||||
{settings.accessControlMethod === 'allowed_list' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Allowed Emails (comma-separated)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.allowedEmails}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, allowedEmails: e.target.value });
|
||||
}}
|
||||
placeholder="user1@example.com, user2@example.com"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enter email addresses separated by commas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Allowed Usernames (comma-separated)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.allowedUsernames}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, allowedUsernames: e.target.value });
|
||||
}}
|
||||
placeholder="john_doe, jane_smith"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enter usernames separated by commas
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Role Mapping Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Admin Role Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Automatically grant admin permissions based on OIDC claims (e.g., group membership). The first user will always become admin.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable Admin Claim Mapping */}
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="admin-claim-enabled"
|
||||
checked={settings.adminClaimEnabled}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, adminClaimEnabled: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="admin-claim-enabled"
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
Enable Admin Role Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically grant admin role to users with specific OIDC claim values
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.adminClaimEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Admin Claim Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.adminClaimName}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, adminClaimName: e.target.value });
|
||||
}}
|
||||
placeholder="groups"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
The OIDC claim field to check for admin role (usually "groups" or "roles")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Admin Claim Value
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.adminClaimValue}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, adminClaimValue: e.target.value });
|
||||
}}
|
||||
placeholder="readmeabook-admin"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Users with this value in their claim will be granted admin role
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Example Configuration */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Example Configuration
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
In Authentik: Create a group called "readmeabook-admin", add users to it, and set "Admin Claim Value" to "readmeabook-admin"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Pending User Approvals
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Review and approve or reject user registration requests.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span className="text-sm text-gray-500">Loading pending users...</span>
|
||||
</div>
|
||||
) : pendingUsers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{pendingUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.plexUsername}
|
||||
</h3>
|
||||
{user.plexEmail && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{user.plexEmail}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
Registered: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => onApprove(user.id, true)}
|
||||
variant="outline"
|
||||
className="border-green-300 text-green-600 hover:bg-green-50 dark:border-green-700 dark:text-green-400 dark:hover:bg-green-900/20"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onApprove(user.id, false)}
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
No pending user approvals
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Manual Registration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure manual user registration settings.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable Registration Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="registration-enabled"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, enabled: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="registration-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Manual Registration
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Allow users to create accounts manually with username/password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Require Admin Approval Toggle */}
|
||||
{settings.enabled && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="require-approval"
|
||||
checked={settings.requireAdminApproval}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, requireAdminApproval: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="require-approval"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Require Admin Approval
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
New users must be approved by an admin before they can log in
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<PendingUser[]>([]);
|
||||
const [loadingPendingUsers, setLoadingPendingUsers] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [oidcTestResult, setOidcTestResult] = useState<TestResult | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
BookDate Configuration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure global AI-powered audiobook recommendations. All users share this API key, but receive personalized recommendations based on their individual library and ratings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable/Disable Toggle */}
|
||||
{configured && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||
BookDate Feature
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{enabled ? 'Feature is currently enabled' : 'Feature is currently disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Provider */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
AI Provider
|
||||
</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
setModels([]);
|
||||
setBaseUrl('');
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Base URL Input - Show for Custom Provider */}
|
||||
{provider === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setBaseUrl(e.target.value);
|
||||
setModels([]);
|
||||
}}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Examples:
|
||||
<br />• Ollama: <code>http://localhost:11434/v1</code>
|
||||
<br />• LM Studio: <code>http://localhost:1234/v1</code>
|
||||
<br />• vLLM: <code>http://localhost:8000/v1</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{provider === 'custom' ? 'API Key (Optional for local models)' : 'API Key'}
|
||||
{provider !== 'custom' && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setModels([]);
|
||||
}}
|
||||
placeholder={
|
||||
provider === 'custom'
|
||||
? 'Leave blank for local models'
|
||||
: configured
|
||||
? '••••••••••••••••'
|
||||
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...')
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test Connection Button */}
|
||||
<Button
|
||||
onClick={() => testConnection(onSuccess, onError)}
|
||||
loading={testing}
|
||||
disabled={
|
||||
provider === 'custom'
|
||||
? !baseUrl.trim()
|
||||
: (!apiKey.trim() && !configured)
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{configured && !apiKey.trim()
|
||||
? 'Test Connection & Fetch Models (using saved API key)'
|
||||
: 'Test Connection & Fetch Models'}
|
||||
</Button>
|
||||
|
||||
{/* Model Selection */}
|
||||
{models.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Select Model
|
||||
</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">-- Choose a model --</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note about per-user settings */}
|
||||
{(models.length > 0 || configured) && model && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<strong>Note:</strong> 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).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
{model && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={() => saveConfig(onSuccess, onError)}
|
||||
loading={saving}
|
||||
disabled={!model}
|
||||
className="w-full"
|
||||
>
|
||||
Save BookDate Configuration
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear Swipe History */}
|
||||
{configured && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Clear All Swipe History
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Remove all swipe history and cached recommendations for ALL users. This will reset everyone's BookDate recommendations.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => clearSwipes(onSuccess, onError)}
|
||||
loading={clearingSwipes}
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Clear Swipe History
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BookDateTab } from './BookDateTab';
|
||||
export { useBookDateSettings } from './useBookDateSettings';
|
||||
@@ -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<string>('openai');
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
const [model, setModel] = useState<string>('');
|
||||
const [baseUrl, setBaseUrl] = useState<string>('');
|
||||
const [enabled, setEnabled] = useState<boolean>(true);
|
||||
const [configured, setConfigured] = useState<boolean>(false);
|
||||
const [models, setModels] = useState<BookDateModel[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Download Client
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Type
|
||||
</label>
|
||||
<select
|
||||
value={downloadClient.type}
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="sabnzbd">SABnzbd</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={downloadClient.url}
|
||||
onChange={(e) => updateField('url', e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* qBittorrent: Username + Password */}
|
||||
{downloadClient.type === 'qbittorrent' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={downloadClient.username}
|
||||
onChange={(e) => updateField('username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={downloadClient.password}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SABnzbd: API Key only */}
|
||||
{downloadClient.type === 'sabnzbd' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={downloadClient.password}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
placeholder="Enter SABnzbd API key"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Find this in SABnzbd under Config → General → API Key
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSL Verification Toggle */}
|
||||
{downloadClient.url.startsWith('https') && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disable-ssl-verify"
|
||||
checked={downloadClient.disableSSLVerify}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="disable-ssl-verify"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Disable SSL Certificate Verification
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Enable this if you're using a self-signed certificate or getting SSL errors.
|
||||
<span className="text-yellow-700 dark:text-yellow-500 font-medium"> ⚠️ Only use on trusted private networks.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remote-path-mapping"
|
||||
checked={downloadClient.remotePathMappingEnabled}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="remote-path-mapping"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Remote Path Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
||||
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
||||
</p>
|
||||
|
||||
{/* Warning for existing downloads */}
|
||||
{downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ <strong>Note:</strong> Path mapping only affects new downloads. In-progress downloads will continue using their original paths.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Remote Path (from qBittorrent)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/remote/mnt/d/done"
|
||||
value={downloadClient.remotePath}
|
||||
onChange={(e) => updateField('remotePath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The path prefix as reported by qBittorrent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Local Path (for ReadMeABook)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/downloads"
|
||||
value={downloadClient.localPath}
|
||||
onChange={(e) => updateField('localPath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The actual path where files are accessible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
disabled={
|
||||
!downloadClient.url ||
|
||||
!downloadClient.password ||
|
||||
(downloadClient.type === 'qbittorrent' && !downloadClient.username)
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Component: Download Client Settings Tab - Export
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
export { DownloadTab } from './DownloadTab';
|
||||
@@ -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<TestResult | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
E-book Sidecar
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Automatically download e-books from Anna's Archive to accompany your audiobooks.
|
||||
E-books are placed in the same folder as the audiobook files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ebook-enabled"
|
||||
checked={ebook.enabled || false}
|
||||
onChange={(e) => updateEbook('enabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="ebook-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable e-book sidecar downloads
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
When enabled, the system will search for e-books matching your audiobook's ASIN
|
||||
and download them to the same folder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
{ebook.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Preferred Format
|
||||
</label>
|
||||
<select
|
||||
value={ebook.preferredFormat || 'epub'}
|
||||
onChange={(e) => updateEbook('preferredFormat', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="epub">EPUB</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="mobi">MOBI</option>
|
||||
<option value="azw3">AZW3</option>
|
||||
<option value="any">Any format</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
EPUB is recommended for most e-readers. "Any format" will download the first available format.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL (Advanced) */}
|
||||
{ebook.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL (Advanced)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||
placeholder="https://annas-archive.li"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Change this if the primary Anna's Archive mirror is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FlareSolverr (Optional - for Cloudflare bypass) */}
|
||||
{ebook.enabled && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
FlareSolverr URL (Optional)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.flaresolverrUrl || ''}
|
||||
onChange={(e) => updateEbook('flaresolverrUrl', e.target.value)}
|
||||
placeholder="http://localhost:8191"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={testFlaresolverrConnection}
|
||||
loading={testingFlaresolverr}
|
||||
variant="secondary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
FlareSolverr helps bypass Cloudflare protection on Anna's Archive.
|
||||
Leave empty if not needed.
|
||||
</p>
|
||||
{flaresolverrTestResult && (
|
||||
<div
|
||||
className={`mt-2 p-3 rounded-lg text-sm ${
|
||||
flaresolverrTestResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
{flaresolverrTestResult.success ? '✓ ' : '✗ '}
|
||||
{flaresolverrTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!ebook.flaresolverrUrl && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Note:</strong> Without FlareSolverr, e-book downloads may fail if Anna's Archive
|
||||
has Cloudflare protection enabled. Success rates are typically lower without it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
How it works
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• Searches Anna's Archive in two ways:</li>
|
||||
<li className="ml-4">1. First tries ASIN (exact match - most accurate)</li>
|
||||
<li className="ml-4">2. Falls back to title + author (with book/language filters)</li>
|
||||
<li>• Downloads matching e-book in your preferred format</li>
|
||||
<li>• Places e-book file in the same folder as the audiobook</li>
|
||||
<li>• If no match is found or download fails, audiobook download continues normally</li>
|
||||
<li>• Completely optional and non-blocking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
||||
⚠️ Important Note
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
loading={saving}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Save E-book Sidecar Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EbookTab } from './EbookTab';
|
||||
export { useEbookSettings } from './useEbookSettings';
|
||||
@@ -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<TestResult | null>(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,
|
||||
};
|
||||
}
|
||||
+49
-38
@@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
@@ -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({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.prowlarr.url || !settings.prowlarr.apiKey}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<IndexerManagement
|
||||
prowlarrUrl={settings.prowlarr.url}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { IndexersTab } from './IndexersTab';
|
||||
export { useIndexersSettings } from './useIndexersSettings';
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Component: Indexers Settings Tab - Custom Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { TestResult } from '../../lib/types';
|
||||
|
||||
interface UseIndexersSettingsProps {
|
||||
prowlarrUrl: string;
|
||||
prowlarrApiKey: string;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onRefreshIndexers?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useIndexersSettings({
|
||||
prowlarrUrl,
|
||||
prowlarrApiKey,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
}: UseIndexersSettingsProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Audiobookshelf Server
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your Audiobookshelf server connection and audiobook library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.audiobookshelf.serverUrl}
|
||||
onChange={(e) => handleServerUrlChange(e.target.value)}
|
||||
placeholder="http://localhost:13378"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Token
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.audiobookshelf.apiToken}
|
||||
onChange={(e) => handleApiTokenChange(e.target.value)}
|
||||
placeholder="Enter your Audiobookshelf API token"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Generate in Audiobookshelf: Settings → API Keys → Add API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Audiobook Library
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
value={settings.audiobookshelf.libraryId}
|
||||
onChange={(e) => handleLibraryChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Select a library...</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 py-2">
|
||||
Test your connection to load libraries.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.audiobookshelf.triggerScanAfterImport}
|
||||
onChange={(e) => handleTriggerScanChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Trigger library scan after import
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically triggers Audiobookshelf to scan its filesystem after organizing downloaded files.
|
||||
Only enable this if you have Audiobookshelf's filesystem watcher (automatic scanning) disabled.
|
||||
Most users should leave this disabled and rely on Audiobookshelf's built-in automatic detection.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Audible Region Selection */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
|
||||
<label
|
||||
htmlFor="audible-region-abs"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Audible Region
|
||||
</label>
|
||||
<select
|
||||
id="audible-region-abs"
|
||||
value={settings.audibleRegion || 'us'}
|
||||
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="us">United States</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="in">India</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
||||
configuration in Audiobookshelf. This ensures accurate book matching and metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={onTestConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.audiobookshelf.serverUrl || !settings.audiobookshelf.apiToken}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PlexSection
|
||||
settings={settings}
|
||||
onChange={onChange}
|
||||
onValidationChange={onValidationChange}
|
||||
libraries={plexLibraries}
|
||||
testing={testingPlex}
|
||||
testResult={plexTestResult}
|
||||
onTestConnection={handleTestPlexConnection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.backendMode === 'audiobookshelf') {
|
||||
return (
|
||||
<AudiobookshelfSection
|
||||
settings={settings}
|
||||
onChange={onChange}
|
||||
onValidationChange={onValidationChange}
|
||||
libraries={absLibraries}
|
||||
testing={testingAbs}
|
||||
testResult={absTestResult}
|
||||
onTestConnection={handleTestABSConnection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen)
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Invalid backend mode. Please configure your backend in setup.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Plex Media Server
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your Plex server connection and audiobook library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.plex.url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="http://localhost:32400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Authentication Token
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.plex.token}
|
||||
onChange={(e) => handleTokenChange(e.target.value)}
|
||||
placeholder="Enter your Plex token"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Find your token in Plex settings → Network → Show Advanced
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Audiobook Library
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
value={settings.plex.libraryId}
|
||||
onChange={(e) => handleLibraryChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Select a library...</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.title} ({lib.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 py-2">
|
||||
Test your connection to load libraries.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.plex.triggerScanAfterImport}
|
||||
onChange={(e) => handleTriggerScanChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Trigger library scan after import
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically triggers Plex to scan its filesystem after organizing downloaded files.
|
||||
Only enable this if you have Plex's filesystem watcher (automatic scanning) disabled.
|
||||
Most users should leave this disabled and rely on Plex's built-in automatic detection.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Audible Region Selection */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
|
||||
<label
|
||||
htmlFor="audible-region"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Audible Region
|
||||
</label>
|
||||
<select
|
||||
id="audible-region"
|
||||
value={settings.audibleRegion || 'us'}
|
||||
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="us">United States</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="in">India</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
||||
configuration in Plex. This ensures accurate book matching and metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={onTestConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.plex.url || !settings.plex.token}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<boolean>;
|
||||
|
||||
// ABS state
|
||||
absLibraries: ABSLibrary[];
|
||||
setAbsLibraries: (libraries: ABSLibrary[]) => void;
|
||||
testingAbs: boolean;
|
||||
absTestResult: { success: boolean; message: string } | null;
|
||||
testABSConnection: (serverUrl: string, apiToken: string) => Promise<boolean>;
|
||||
|
||||
// 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<PlexLibrary[]>([]);
|
||||
const [testingPlex, setTestingPlex] = useState(false);
|
||||
const [plexTestResult, setPlexTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// ABS state
|
||||
const [absLibraries, setAbsLibraries] = useState<ABSLibrary[]>([]);
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Directory Paths
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure download and media directory paths.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Download Directory */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Download Directory
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.downloadDir}
|
||||
onChange={(e) => updatePath('downloadDir', e.target.value)}
|
||||
placeholder="/downloads"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Temporary location for torrent downloads (kept for seeding)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Media Directory */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Media Directory
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.mediaDir}
|
||||
onChange={(e) => updatePath('mediaDir', e.target.value)}
|
||||
placeholder="/media/audiobooks"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Final location for organized audiobook library (Your backend scans this directory)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata Tagging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="metadata-tagging-settings"
|
||||
checked={paths.metadataTaggingEnabled}
|
||||
onChange={(e) => updatePath('metadataTaggingEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="metadata-tagging-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-tag audio files with metadata
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter Merging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="chapter-merging-settings"
|
||||
checked={paths.chapterMergingEnabled}
|
||||
onChange={(e) => updatePath('chapterMergingEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="chapter-merging-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-merge chapters to M4B
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter
|
||||
markers. Improves playback experience and library organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Paths Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testPaths}
|
||||
loading={testing}
|
||||
disabled={!paths.downloadDir || !paths.mediaDir}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Paths
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PathsTab } from './PathsTab';
|
||||
export { usePathsSettings } from './usePathsSettings';
|
||||
@@ -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<TestResult | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,9 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
async initiateLogin(): Promise<LoginInitiation> {
|
||||
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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, any>) => ({
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, any>) => ({
|
||||
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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>) => ({
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>) => ({
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>(),
|
||||
}));
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[] }) => (
|
||||
<div data-testid="indexer-management">
|
||||
{initialIndexers.length > 0 ? (
|
||||
<div data-testid="indexers-loaded">
|
||||
{initialIndexers.length} indexers loaded
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="indexers-empty">No indexers</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/admin/FlagConfigRow', () => ({
|
||||
FlagConfigRow: () => <div data-testid="flag-config-row">Flag Config</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Button', () => ({
|
||||
Button: ({ children, onClick, loading, disabled, ...props }: any) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
data-testid={props['data-testid'] || 'button'}
|
||||
>
|
||||
{loading ? 'Loading...' : children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Input', () => ({
|
||||
Input: (props: any) => <input {...props} data-testid={props['data-testid'] || 'input'} />,
|
||||
}));
|
||||
|
||||
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(
|
||||
<IndexersTab
|
||||
settings={mockSettings}
|
||||
indexers={[]}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('indexers-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display indexers when indexers prop contains data', () => {
|
||||
render(
|
||||
<IndexersTab
|
||||
settings={mockSettings}
|
||||
indexers={mockIndexers}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<IndexersTab
|
||||
settings={mockSettings}
|
||||
indexers={[]} // Start with empty
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
onRefreshIndexers={mockOnRefreshIndexers}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<IndexersTab
|
||||
settings={settingsWithoutUrl}
|
||||
indexers={[]}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
onRefreshIndexers={mockOnRefreshIndexers}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<IndexersTab
|
||||
settings={settingsWithoutApiKey}
|
||||
indexers={[]}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
onRefreshIndexers={mockOnRefreshIndexers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should NOT call onRefreshIndexers because API key is missing
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(mockOnRefreshIndexers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component: Prisma Mock Factory
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
type PrismaModelMock = {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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(),
|
||||
});
|
||||
@@ -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 `
|
||||
<div class="productListItem">
|
||||
<li data-asin="${asin}"></li>
|
||||
<h3><a>Title ${i + 1}</a></h3>
|
||||
<span class="authorLabel">By: Author ${i + 1}</span>
|
||||
<span class="narratorLabel">Narrated by: Narrator ${i + 1}</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="ratingsLabel">4.${i} out of 5 stars</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
it('parses search results from HTML', async () => {
|
||||
const html = `
|
||||
<div class="s-result-item">
|
||||
<li data-asin="B000123456"></li>
|
||||
<h2>The Test Book</h2>
|
||||
<a href="/author/Author-Name">Author Name</a>
|
||||
<span class="narratorLabel">Narrated by: Narrator Name</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="runtimeLabel">Length: 5 hrs and 30 mins</span>
|
||||
<span class="ratingsLabel">4.5 out of 5 stars</span>
|
||||
</div>
|
||||
<div class="resultsInfo">1-20 of 55 results</div>
|
||||
`;
|
||||
|
||||
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 = `<div class="resultsInfo">0 results</div>`;
|
||||
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 = `<div class="resultsInfo">0 results</div>`;
|
||||
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 = `<div class="resultsInfo">0 results</div>`;
|
||||
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 = `
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000111111"></li>
|
||||
<h3><a>Popular One</a></h3>
|
||||
<span class="authorLabel">By: Author One</span>
|
||||
<span class="narratorLabel">Narrated by: Narrator One</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="ratingsLabel">4.2 out of 5 stars</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000222222"></li>
|
||||
<h3><a>Title One</a></h3>
|
||||
</div>
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000222222"></li>
|
||||
<h3><a>Title Two</a></h3>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<script type="application/ld+json">{invalid}</script>
|
||||
<div class="product-top-section">
|
||||
<h1 class="bc-heading">HTML Title</h1>
|
||||
<li class="authorLabel"><a>By: HTML Author</a></li>
|
||||
<li class="narratorLabel"><a>Narrated by: HTML Narrator</a></li>
|
||||
<li class="runtimeLabel"><span>Length: 2 hrs and 5 mins</span></li>
|
||||
<li>Release date: Jan 2, 2022</li>
|
||||
<span class="ratingsLabel">4.8 out of 5 stars</span>
|
||||
<img class="bc-image-inset-border" src="https://images.example.com/cover._SL200_.jpg" />
|
||||
<div class="bc-expander-content">
|
||||
This is a long description for testing the Audible HTML parsing logic.
|
||||
</div>
|
||||
<a href="/cat/fiction">Fiction</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@type": "Product",
|
||||
"name": "Fallback Book",
|
||||
"author": {"name": "Fallback Author"},
|
||||
"readBy": {"name": "Fallback Narrator"},
|
||||
"description": "A long description that exceeds fifty characters for validation.",
|
||||
"image": "https://images.example.com/cover._SL200_.jpg",
|
||||
"aggregateRating": { "ratingValue": "4.6" },
|
||||
"datePublished": "Jan 1, 2024",
|
||||
"duration": "PT8H30M"
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
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 = `<div class="resultsInfo">0 results</div>`;
|
||||
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: '<div class="product-top-section"><h1 class="bc-heading">Dev Book</h1></div>',
|
||||
});
|
||||
|
||||
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 = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@type": "Product",
|
||||
"name": "Array Book",
|
||||
"author": [{"name": "Author One"}, {"name": "Author Two"}],
|
||||
"readBy": [{"name": "Narrator One"}, {"name": "Narrator Two"}],
|
||||
"description": "A description that is long enough to be accepted in tests.",
|
||||
"image": "https://images.example.com/cover._SL200_.jpg",
|
||||
"duration": "PT1H30M"
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="product-top-section">
|
||||
<a href="/author/Author-One">Author One</a>
|
||||
<a href="/author/See-All">See all</a>
|
||||
<a href="/narrator/Narr-One">Narrator One</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<p>This description is intentionally long enough to satisfy the minimum length requirement for parsing.</p>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<span>10 hr 2 min</span>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000TIME');
|
||||
|
||||
expect(details?.durationMinutes).toBe(602);
|
||||
});
|
||||
});
|
||||
@@ -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: '<xml />' });
|
||||
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: '<xml />' });
|
||||
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: '<xml />' });
|
||||
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: '<xml />' });
|
||||
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: '<xml />' });
|
||||
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: '<xml />' });
|
||||
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: '<xml />' });
|
||||
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: '<xml />' });
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 = `
|
||||
<rss xmlns:torznab="http://torznab.com/schemas/2015/feed">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Great Book M4B 64kbps</title>
|
||||
<link>https://example.com/book.torrent</link>
|
||||
<guid>guid-1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
|
||||
<prowlarrindexer>IndexerA</prowlarrindexer>
|
||||
<torznab:attr name="seeders" value="5" />
|
||||
<torznab:attr name="peers" value="8" />
|
||||
<torznab:attr name="infohash" value="HASH" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<rss xmlns:torznab="http://torznab.com/schemas/2015/feed">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Book Without Link</title>
|
||||
<guid>guid-2</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
|
||||
<prowlarrindexer>IndexerA</prowlarrindexer>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>;
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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<string, string | null>) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user