mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add series fields to audiobooks and update related logic
Introduces 'series' and 'seriesPart' fields to the Audiobook model and database schema. Updates API routes, file organization, and path template utilities to support series metadata. Enhances chapter merging logic, improves notification backend testing, and expands test coverage for admin and API routes.
This commit is contained in:
@@ -14,7 +14,6 @@
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://github.com/kikootwo/readmeabook/stargazers)
|
||||
[](https://discord.gg/kaw6jKbKts)
|
||||
|
||||
</div>
|
||||
|
||||
*Radarr/Sonarr + Overseerr for audiobooks, all in one*
|
||||
|
||||
@@ -164,14 +164,14 @@ ffmpeg -y -f concat -safe 0 -i filelist.txt \
|
||||
**For MP3 files (requires conversion - v2 enhanced):**
|
||||
```bash
|
||||
# Re-encode to M4B (AAC) with quality preservation
|
||||
# Uses libfdk_aac if available (higher quality) or native aac
|
||||
# Uses libfdk_aac if available (higher quality) or native aac with VBR
|
||||
ffmpeg -y -f concat -safe 0 -i filelist.txt \
|
||||
-i chapters.txt \
|
||||
-map_metadata 1 \
|
||||
-map 0:a \
|
||||
-c:a libfdk_aac -vbr 4 \ # High quality AAC (or: -c:a aac -b:a <source_bitrate> -profile:a aac_low)
|
||||
-c:a libfdk_aac -vbr 4 \ # High quality AAC (or: -c:a aac -q:a <quality> -profile:a aac_low)
|
||||
-movflags +faststart \ # CRITICAL: Instant playback
|
||||
-fflags +genpts \ # Fix timestamps
|
||||
-fflags +genpts \ # Fix timestamps (regenerates presentation timestamps)
|
||||
-avoid_negative_ts make_zero \ # Handle edge cases
|
||||
-max_muxing_queue_size 9999 \ # Long file support
|
||||
-metadata title="Book Title" \
|
||||
@@ -182,16 +182,18 @@ ffmpeg -y -f concat -safe 0 -i filelist.txt \
|
||||
|
||||
**Quality Settings (MP3 → M4B - v2):**
|
||||
- **Bitrate:** Matches source average (64-320kbps range)
|
||||
- Example: 128kbps MP3 source → 128kbps AAC output
|
||||
- Example: 192kbps MP3 source → 192kbps AAC output
|
||||
- **Encoder:** libfdk_aac (VBR mode 4, high quality) if available, else native aac
|
||||
- Example: 128kbps MP3 source → ~128kbps AAC output
|
||||
- Example: 192kbps MP3 source → ~192kbps AAC output
|
||||
- **Encoder:** libfdk_aac (VBR mode 4, high quality) if available, else native aac with VBR
|
||||
- Native AAC uses variable bitrate for better quality/efficiency
|
||||
- Quality mapped dynamically: 64k→q1.0, 128k→q2.0, 192k→q3.0, 256k→q4.0
|
||||
- **Profile:** AAC-LC (maximum compatibility)
|
||||
- **Sampling rate:** Preserved from source
|
||||
- **Channels:** Preserved (mono/stereo)
|
||||
|
||||
**Critical Flags (v2):**
|
||||
- **`-movflags +faststart`**: Moves moov atom to file beginning → instant playback (fixes 1-min delay)
|
||||
- **`-fflags +genpts`**: Regenerates presentation timestamps → fixes seeking/timing issues
|
||||
- **`-fflags +genpts`**: Regenerates presentation timestamps for audio/video → fixes seeking/timing issues at concat boundaries
|
||||
- **`-avoid_negative_ts make_zero`**: Handles negative timestamps at concat boundaries
|
||||
- **`-max_muxing_queue_size 9999`**: Prevents buffer overflow on long audiobooks (16h+)
|
||||
|
||||
@@ -466,8 +468,8 @@ timeout = 5 minutes + (chapter_count * 30 seconds)
|
||||
All merged files are validated before marked successful:
|
||||
|
||||
1. **Duration Check:** Expected vs actual duration (within 2% tolerance)
|
||||
2. **Decode Test:** FFmpeg attempts to decode first 10 seconds (catches corruption)
|
||||
3. **Size Check:** File size reasonable for duration (~0.5MB/min minimum)
|
||||
2. **Decode Test:** FFmpeg attempts to decode first and last 10 seconds (catches corruption/truncation)
|
||||
3. **Size Check:** File size reasonable for duration (~0.4MB/min minimum, accommodates 64kbps encoding)
|
||||
|
||||
**If validation fails:**
|
||||
- Corrupt file is deleted
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# Testing
|
||||
|
||||
**Status:** ⏳ In Progress | Backend unit testing framework (Vitest)
|
||||
**Status:** ƒ?3 In Progress | Backend + frontend unit testing framework (Vitest)
|
||||
|
||||
## Overview
|
||||
Unit tests for backend logic with isolated mocks (Prisma, integrations, queue).
|
||||
Backend unit tests (Node) and frontend component tests (jsdom) with isolated mocks and deterministic helpers.
|
||||
|
||||
## 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`
|
||||
- **Runner:** Vitest (`vitest.config.ts`)
|
||||
- **Environments:** Node for `*.test.ts`, jsdom for `*.test.tsx` via `environmentMatchGlobs`
|
||||
- **Setup:** `tests/setup.ts` sets `NODE_ENV=test`, `TZ=UTC`, jest-dom, DOM polyfills, Next.js `Link/Image` mocks
|
||||
- **Frontend helpers:** `tests/helpers/render.tsx`, `tests/helpers/mock-auth.ts`, `tests/helpers/mock-next-navigation.ts`
|
||||
- **Backend 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
|
||||
- **Scope:** Unit tests only; no real network or services
|
||||
|
||||
## API/Interfaces
|
||||
```
|
||||
@@ -21,8 +23,10 @@ npm run test:coverage
|
||||
```
|
||||
|
||||
## Critical Issues
|
||||
- API route unit tests are incomplete; add route-level mocks before enforcing coverage.
|
||||
- Frontend coverage not yet enforced; expand component/page tests before adding coverage gates.
|
||||
|
||||
## Related
|
||||
- [frontend/components.md](frontend/components.md)
|
||||
- [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||
- [backend/services/jobs.md](backend/services/jobs.md)
|
||||
- [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AddSeriesFields
|
||||
ALTER TABLE "audiobooks" ADD COLUMN "series" TEXT;
|
||||
ALTER TABLE "audiobooks" ADD COLUMN "series_part" TEXT;
|
||||
@@ -168,6 +168,8 @@ model Audiobook {
|
||||
description String? @db.Text
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
year Int? // Release year extracted from releaseDate
|
||||
series String? // Book series name (e.g., "The Mistborn Saga")
|
||||
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
|
||||
|
||||
// Request tracking
|
||||
status String @default("requested") // requested, downloading, processing, completed, failed
|
||||
|
||||
@@ -45,7 +45,10 @@ export function RequestActionsDropdown({
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||
// Only show "View Source" if we have a valid indexer page URL (not a magnet link)
|
||||
const canViewSource = !!request.torrentUrl &&
|
||||
!request.torrentUrl.startsWith('magnet:') &&
|
||||
['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||
const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
|
||||
@@ -117,12 +117,15 @@ export function NotificationsTab() {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
// In edit mode, use backend ID to test with real config (masked values won't work)
|
||||
// In add mode, use the form config directly
|
||||
const testPayload = modalState.mode === 'edit' && modalState.backend
|
||||
? { backendId: modalState.backend.id }
|
||||
: { type: modalState.selectedType, config: formData.config };
|
||||
|
||||
const response = await fetchWithAuth('/api/admin/notifications/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: modalState.selectedType,
|
||||
config: formData.config,
|
||||
}),
|
||||
body: JSON.stringify(testPayload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -137,6 +137,14 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,12 +8,29 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar
|
||||
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
||||
|
||||
const TestNotificationSchema = z.object({
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
config: z.record(z.any()),
|
||||
const TestNotificationSchema = z.discriminatedUnion('mode', [
|
||||
// Test existing backend by ID (uses stored config)
|
||||
z.object({
|
||||
mode: z.literal('backend'),
|
||||
backendId: z.string(),
|
||||
}),
|
||||
// Test new config before saving
|
||||
z.object({
|
||||
mode: z.literal('config'),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
config: z.record(z.any()),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Support legacy format without mode
|
||||
const LegacyTestNotificationSchema = z.object({
|
||||
backendId: z.string().optional(),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(),
|
||||
config: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -25,12 +42,67 @@ export async function POST(request: NextRequest) {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, config } = TestNotificationSchema.parse(body);
|
||||
|
||||
// Support legacy format for backward compatibility
|
||||
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
|
||||
|
||||
let type: NotificationBackendType;
|
||||
let encryptedConfig: any;
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Encrypt config values
|
||||
const encryptedConfig = notificationService.encryptConfig(type, config);
|
||||
if (legacyParsed.success) {
|
||||
// Legacy format
|
||||
if (legacyParsed.data.backendId) {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: legacyParsed.data.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else if (legacyParsed.data.type && legacyParsed.data.config) {
|
||||
// Test new config
|
||||
type = legacyParsed.data.type as NotificationBackendType;
|
||||
encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// New format with discriminated union
|
||||
const parsed = TestNotificationSchema.parse(body);
|
||||
|
||||
if (parsed.mode === 'backend') {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: parsed.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else {
|
||||
// Test new config
|
||||
type = parsed.type;
|
||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||
}
|
||||
}
|
||||
|
||||
// Create test payload
|
||||
const testPayload: NotificationPayload = {
|
||||
|
||||
@@ -113,8 +113,10 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch full details from Audnexus to get releaseDate and year
|
||||
// Fetch full details from Audnexus to get releaseDate, year, and series
|
||||
let year: number | undefined;
|
||||
let series: string | undefined;
|
||||
let seriesPart: string | undefined;
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
||||
@@ -130,6 +132,16 @@ export async function POST(request: NextRequest) {
|
||||
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract series data
|
||||
if (audnexusData?.series) {
|
||||
series = audnexusData.series;
|
||||
logger.debug(`Extracted series: ${series}`);
|
||||
}
|
||||
if (audnexusData?.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
logger.debug(`Extracted seriesPart: ${seriesPart}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
@@ -150,17 +162,23 @@ export async function POST(request: NextRequest) {
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
year,
|
||||
series,
|
||||
seriesPart,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`);
|
||||
} else if (year) {
|
||||
// Always update year if we have it from Audnexus (even if audiobook already has one)
|
||||
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
|
||||
} else if (year || series || seriesPart) {
|
||||
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
|
||||
audiobookRecord = await prisma.audiobook.update({
|
||||
where: { id: audiobookRecord.id },
|
||||
data: { year },
|
||||
data: {
|
||||
...(year && { year }),
|
||||
...(series && { series }),
|
||||
...(seriesPart && { seriesPart }),
|
||||
},
|
||||
});
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`);
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Check if user already has an active (non-deleted) request for this audiobook
|
||||
|
||||
@@ -63,8 +63,10 @@ async function handler(req: AuthenticatedRequest) {
|
||||
// If swiped right and not marked as known, create request
|
||||
if (action === 'right' && !markedAsKnown && recommendation.audnexusAsin) {
|
||||
try {
|
||||
// Fetch full details from Audnexus to get releaseDate and year
|
||||
// Fetch full details from Audnexus to get releaseDate, year, and series
|
||||
let year: number | undefined;
|
||||
let series: string | undefined;
|
||||
let seriesPart: string | undefined;
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin);
|
||||
@@ -80,6 +82,16 @@ async function handler(req: AuthenticatedRequest) {
|
||||
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract series data
|
||||
if (audnexusData?.series) {
|
||||
series = audnexusData.series;
|
||||
logger.debug(`Extracted series: ${series}`);
|
||||
}
|
||||
if (audnexusData?.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
logger.debug(`Extracted seriesPart: ${seriesPart}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${recommendation.audnexusAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
@@ -89,7 +101,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
where: { audibleAsin: recommendation.audnexusAsin },
|
||||
});
|
||||
|
||||
// If not, create it with year
|
||||
// If not, create it with year and series
|
||||
if (!audiobook) {
|
||||
audiobook = await prisma.audiobook.create({
|
||||
data: {
|
||||
@@ -100,17 +112,23 @@ async function handler(req: AuthenticatedRequest) {
|
||||
description: recommendation.description,
|
||||
coverArtUrl: recommendation.coverUrl,
|
||||
year,
|
||||
series,
|
||||
seriesPart,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
logger.debug(`Created audiobook ${audiobook.id} with year: ${year || 'none'}`);
|
||||
} else if (year) {
|
||||
// Always update year if we have it from Audnexus (even if audiobook already has one)
|
||||
logger.debug(`Created audiobook ${audiobook.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
|
||||
} else if (year || series || seriesPart) {
|
||||
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
|
||||
audiobook = await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: { year },
|
||||
data: {
|
||||
...(year && { year }),
|
||||
...(series && { series }),
|
||||
...(seriesPart && { seriesPart }),
|
||||
},
|
||||
});
|
||||
logger.debug(`Updated audiobook ${audiobook.id} with year ${year}`);
|
||||
logger.debug(`Updated audiobook ${audiobook.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Create request (if not already exists)
|
||||
|
||||
@@ -97,8 +97,10 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch full details from Audnexus to get releaseDate and year
|
||||
// Fetch full details from Audnexus to get releaseDate, year, and series
|
||||
let year: number | undefined;
|
||||
let series: string | undefined;
|
||||
let seriesPart: string | undefined;
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
|
||||
@@ -114,6 +116,16 @@ export async function POST(request: NextRequest) {
|
||||
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract series data
|
||||
if (audnexusData?.series) {
|
||||
series = audnexusData.series;
|
||||
logger.debug(`Extracted series: ${series}`);
|
||||
}
|
||||
if (audnexusData?.seriesPart) {
|
||||
seriesPart = audnexusData.seriesPart;
|
||||
logger.debug(`Extracted seriesPart: ${seriesPart}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
@@ -134,17 +146,23 @@ export async function POST(request: NextRequest) {
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
year,
|
||||
series,
|
||||
seriesPart,
|
||||
status: 'requested',
|
||||
},
|
||||
});
|
||||
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`);
|
||||
} else if (year) {
|
||||
// Always update year if we have it from Audnexus (even if audiobook already has one)
|
||||
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
|
||||
} else if (year || series || seriesPart) {
|
||||
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
|
||||
audiobookRecord = await prisma.audiobook.update({
|
||||
where: { id: audiobookRecord.id },
|
||||
data: { year },
|
||||
data: {
|
||||
...(year && { year }),
|
||||
...(series && { series }),
|
||||
...(seriesPart && { seriesPart }),
|
||||
},
|
||||
});
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`);
|
||||
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
|
||||
}
|
||||
|
||||
// Check if user already has an active (non-deleted) request for this audiobook
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface AudibleAudiobook {
|
||||
releaseDate?: string;
|
||||
rating?: number;
|
||||
genres?: string[];
|
||||
series?: string;
|
||||
seriesPart?: string;
|
||||
}
|
||||
|
||||
export interface AudibleSearchResult {
|
||||
@@ -492,6 +494,8 @@ export class AudibleService {
|
||||
releaseDate: data.releaseDate || undefined,
|
||||
rating: data.rating ? parseFloat(data.rating) : undefined,
|
||||
genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined,
|
||||
series: data.seriesPrimary?.name || undefined,
|
||||
seriesPart: data.seriesPrimary?.position || undefined,
|
||||
};
|
||||
|
||||
// Ensure cover art URL is high quality
|
||||
@@ -506,7 +510,9 @@ export class AudibleService {
|
||||
descLength: result.description?.length || 0,
|
||||
duration: result.durationMinutes,
|
||||
rating: result.rating,
|
||||
genreCount: result.genres?.length || 0
|
||||
genreCount: result.genres?.length || 0,
|
||||
series: result.series,
|
||||
seriesPart: result.seriesPart
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -60,6 +60,9 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
logger.info(`NZB added with ID: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
@@ -69,7 +72,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
torrentName: torrent.title,
|
||||
nzbId: downloadClientId, // Store NZB ID
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid)
|
||||
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
|
||||
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
|
||||
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
|
||||
leechers: 0,
|
||||
@@ -121,6 +124,9 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
logger.info(`Torrent added with hash: ${downloadClientId}`);
|
||||
|
||||
// Create DownloadHistory record
|
||||
// Determine indexer page URL - exclude magnet links from guid fallback
|
||||
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
|
||||
|
||||
const downloadHistory = await prisma.downloadHistory.create({
|
||||
data: {
|
||||
requestId,
|
||||
@@ -130,7 +136,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
torrentName: torrent.title,
|
||||
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid)
|
||||
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
|
||||
magnetLink: torrent.downloadUrl,
|
||||
seeders: torrent.seeders || 0,
|
||||
leechers: torrent.leechers || 0,
|
||||
|
||||
@@ -94,6 +94,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
coverArtUrl: audiobook.coverArtUrl || undefined,
|
||||
asin: audiobook.audibleAsin || undefined,
|
||||
year,
|
||||
series: audiobook.series || undefined,
|
||||
seriesPart: audiobook.seriesPart || undefined,
|
||||
},
|
||||
template,
|
||||
jobId ? { jobId, context: 'FileOrganizer' } : undefined
|
||||
|
||||
@@ -447,6 +447,23 @@ function determineOutputBitrate(chapters: ChapterFile[]): string {
|
||||
return `${finalBitrate}k`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map bitrate to native AAC VBR quality value
|
||||
* Quality range: 0.1-5 (higher = better quality/larger file)
|
||||
*/
|
||||
function bitrateToVbrQuality(bitrateStr: string): number {
|
||||
const bitrate = parseInt(bitrateStr.replace('k', ''));
|
||||
|
||||
// Approximate mapping based on AAC VBR behavior
|
||||
if (bitrate <= 64) return 1.0; // ~64kbps
|
||||
if (bitrate <= 96) return 1.5; // ~96kbps
|
||||
if (bitrate <= 128) return 2.0; // ~128kbps
|
||||
if (bitrate <= 160) return 2.5; // ~160kbps
|
||||
if (bitrate <= 192) return 3.0; // ~192kbps
|
||||
if (bitrate <= 256) return 4.0; // ~256kbps
|
||||
return 4.5; // ~320kbps+ (max quality)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if libfdk_aac encoder is available (higher quality than native AAC)
|
||||
*/
|
||||
@@ -640,10 +657,12 @@ export async function mergeChapters(
|
||||
args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality)
|
||||
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
|
||||
} else {
|
||||
// Use VBR for better quality at same average bitrate
|
||||
const vbrQuality = bitrateToVbrQuality(bitrate);
|
||||
args.push('-c:a', 'aac');
|
||||
args.push('-b:a', bitrate);
|
||||
args.push('-q:a', vbrQuality.toString());
|
||||
args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility
|
||||
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC at ${bitrate}`);
|
||||
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
|
||||
}
|
||||
} else {
|
||||
// M4A/M4B -> M4B can use codec copy (fast, lossless)
|
||||
@@ -838,7 +857,7 @@ async function validateMergedFile(
|
||||
const stats = await fs.stat(outputPath);
|
||||
const sizeMB = stats.size / 1024 / 1024;
|
||||
const durationMinutes = expectedDuration / 1000 / 60;
|
||||
const expectedMinSize = durationMinutes * 0.5; // ~0.5MB per minute minimum for compressed audio
|
||||
const expectedMinSize = durationMinutes * 0.4; // ~0.4MB per minute minimum (accommodates 64kbps encoding)
|
||||
|
||||
if (sizeMB < expectedMinSize) {
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface AudiobookMetadata {
|
||||
year?: number;
|
||||
coverArtUrl?: string;
|
||||
asin?: string;
|
||||
series?: string;
|
||||
seriesPart?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationResult {
|
||||
@@ -275,7 +277,9 @@ export class FileOrganizer {
|
||||
audiobook.title,
|
||||
audiobook.narrator,
|
||||
audiobook.asin,
|
||||
audiobook.year
|
||||
audiobook.year,
|
||||
audiobook.series,
|
||||
audiobook.seriesPart
|
||||
);
|
||||
|
||||
await logger?.info(`Target path: ${targetPath}`);
|
||||
@@ -556,7 +560,9 @@ export class FileOrganizer {
|
||||
title: string,
|
||||
narrator?: string,
|
||||
asin?: string,
|
||||
year?: number
|
||||
year?: number,
|
||||
series?: string,
|
||||
seriesPart?: string
|
||||
): string {
|
||||
const variables: TemplateVariables = {
|
||||
author,
|
||||
@@ -564,6 +570,8 @@ export class FileOrganizer {
|
||||
narrator,
|
||||
asin,
|
||||
year,
|
||||
series,
|
||||
seriesPart,
|
||||
};
|
||||
|
||||
const relativePath = substituteTemplate(template, variables);
|
||||
@@ -713,7 +721,7 @@ export async function getFileOrganizer(): Promise<FileOrganizer> {
|
||||
export function buildAudiobookPath(
|
||||
baseDir: string,
|
||||
template: string,
|
||||
variables: { author: string; title: string; narrator?: string; asin?: string; year?: number }
|
||||
variables: { author: string; title: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string }
|
||||
): string {
|
||||
const templateVars: TemplateVariables = {
|
||||
author: variables.author,
|
||||
@@ -721,6 +729,8 @@ export function buildAudiobookPath(
|
||||
narrator: variables.narrator,
|
||||
asin: variables.asin,
|
||||
year: variables.year,
|
||||
series: variables.series,
|
||||
seriesPart: variables.seriesPart,
|
||||
};
|
||||
|
||||
const relativePath = substituteTemplate(template, templateVars);
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface TemplateVariables {
|
||||
narrator?: string;
|
||||
asin?: string;
|
||||
year?: number;
|
||||
series?: string;
|
||||
seriesPart?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +30,7 @@ export interface ValidationResult {
|
||||
/**
|
||||
* Supported template variable names
|
||||
*/
|
||||
const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year'];
|
||||
const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year', 'series', 'seriesPart'];
|
||||
|
||||
/**
|
||||
* Invalid file path characters (outside of template variables)
|
||||
@@ -228,14 +230,18 @@ export function generateMockPreviews(template: string): string[] {
|
||||
title: 'Mistborn: The Final Empire',
|
||||
narrator: 'Michael Kramer',
|
||||
asin: 'B002UZMLXM',
|
||||
year: 2006
|
||||
year: 2006,
|
||||
series: 'The Mistborn Saga',
|
||||
seriesPart: '1'
|
||||
},
|
||||
{
|
||||
author: 'Douglas Adams',
|
||||
title: "The Hitchhiker's Guide to the Galaxy",
|
||||
narrator: 'Stephen Fry',
|
||||
asin: 'B0009JKV9W',
|
||||
year: 2005
|
||||
year: 2005,
|
||||
series: "Hitchhiker's Guide",
|
||||
seriesPart: '1'
|
||||
},
|
||||
{
|
||||
author: 'Andy Weir',
|
||||
@@ -243,6 +249,7 @@ export function generateMockPreviews(template: string): string[] {
|
||||
// No narrator for this example
|
||||
asin: 'B08G9PRS1K',
|
||||
year: 2021
|
||||
// No series data - to test empty handling
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -44,6 +44,29 @@ describe('Admin BookDate toggle route', () => {
|
||||
expect(payload.isEnabled).toBe(true);
|
||||
expect(prismaMock.bookDateConfig.updateMany).toHaveBeenCalledWith({ data: { isEnabled: true } });
|
||||
});
|
||||
|
||||
it('rejects non-boolean toggle values', async () => {
|
||||
authRequest.json.mockResolvedValue({ isEnabled: 'yes' });
|
||||
|
||||
const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route');
|
||||
const response = await PATCH({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/must be a boolean/);
|
||||
});
|
||||
|
||||
it('returns 500 when toggle fails', async () => {
|
||||
authRequest.json.mockResolvedValue({ isEnabled: false });
|
||||
prismaMock.bookDateConfig.updateMany.mockRejectedValue(new Error('toggle failed'));
|
||||
|
||||
const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route');
|
||||
const response = await PATCH({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/toggle failed/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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() }));
|
||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -32,7 +33,7 @@ vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: async () => ({ getNZB: vi.fn() }),
|
||||
getSABnzbdService: async () => sabnzbdMock,
|
||||
}));
|
||||
|
||||
describe('Admin downloads route', () => {
|
||||
@@ -65,6 +66,52 @@ describe('Admin downloads route', () => {
|
||||
expect(payload.downloads[0].speed).toBe(123);
|
||||
expect(payload.downloads[0].torrentName).toBe('Torrent');
|
||||
});
|
||||
|
||||
it('returns formatted active downloads for SABnzbd', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-2',
|
||||
status: 'downloading',
|
||||
progress: 20,
|
||||
updatedAt: new Date(),
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'NZB', downloadStatus: 'downloading' }],
|
||||
},
|
||||
]);
|
||||
configServiceMock.get.mockResolvedValueOnce('sabnzbd');
|
||||
sabnzbdMock.getNZB.mockResolvedValueOnce({ downloadSpeed: 555, timeLeft: 120 });
|
||||
|
||||
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(555);
|
||||
expect(payload.downloads[0].eta).toBe(120);
|
||||
});
|
||||
|
||||
it('returns defaults when download client lookup fails', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-3',
|
||||
status: 'downloading',
|
||||
progress: 80,
|
||||
updatedAt: new Date(),
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }],
|
||||
},
|
||||
]);
|
||||
configServiceMock.get.mockResolvedValueOnce('qbittorrent');
|
||||
qbittorrentMock.getTorrent.mockRejectedValueOnce(new Error('client down'));
|
||||
|
||||
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(0);
|
||||
expect(payload.downloads[0].eta).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,41 @@ describe('Admin job status route', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.job.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('rejects non-admin tokens', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
|
||||
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(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 404 when job is missing', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
|
||||
jobQueueMock.getJob.mockResolvedValue(null);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/job-status/[id]/route');
|
||||
const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('Job not found');
|
||||
});
|
||||
|
||||
it('returns 500 when job lookup fails', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
|
||||
jobQueueMock.getJob.mockRejectedValue(new Error('lookup failed'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('InternalError');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.jobs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects job list when missing token', async () => {
|
||||
const { GET } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await GET(makeRequest() as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job list for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
const { GET } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await GET(makeRequest('Bearer token') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job list fails', async () => {
|
||||
schedulerMock.getScheduledJobs.mockRejectedValue(new Error('boom'));
|
||||
const { GET } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await GET(makeRequest('Bearer token') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('InternalError');
|
||||
});
|
||||
|
||||
it('creates a scheduled job', async () => {
|
||||
schedulerMock.createScheduledJob.mockResolvedValue({ id: 'job-2' });
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
@@ -55,6 +84,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.job.id).toBe('job-2');
|
||||
});
|
||||
|
||||
it('rejects job creation when missing token', async () => {
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await POST(makeRequest() as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job creation for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await POST(makeRequest('Bearer token') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job creation fails', async () => {
|
||||
schedulerMock.createScheduledJob.mockRejectedValue(new Error('create failed'));
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
const response = await POST(makeRequest('Bearer token', { name: 'Job' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/create failed/);
|
||||
});
|
||||
|
||||
it('updates a scheduled job', async () => {
|
||||
schedulerMock.updateScheduledJob.mockResolvedValue({ id: 'job-3' });
|
||||
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
@@ -65,6 +123,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects job updates when missing token', async () => {
|
||||
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
const response = await PUT(makeRequest() as any, { params: Promise.resolve({ id: 'job-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job updates for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
const response = await PUT(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job update fails', async () => {
|
||||
schedulerMock.updateScheduledJob.mockRejectedValue(new Error('update failed'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/update failed/);
|
||||
});
|
||||
|
||||
it('deletes a scheduled job', async () => {
|
||||
schedulerMock.deleteScheduledJob.mockResolvedValue(undefined);
|
||||
const { DELETE } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
@@ -75,6 +162,35 @@ describe('Admin jobs routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects job deletion when missing token', async () => {
|
||||
const { DELETE } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
const response = await DELETE(makeRequest() as any, { params: Promise.resolve({ id: 'job-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects job deletion for non-admin users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'user' });
|
||||
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(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Admin access required/);
|
||||
});
|
||||
|
||||
it('returns 500 when job deletion fails', async () => {
|
||||
schedulerMock.deleteScheduledJob.mockRejectedValue(new Error('delete failed'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/delete failed/);
|
||||
});
|
||||
|
||||
it('triggers a scheduled job', async () => {
|
||||
schedulerMock.triggerJobNow.mockResolvedValue('job-5');
|
||||
const { POST } = await import('@/app/api/admin/jobs/[id]/trigger/route');
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
prismaMock.notificationBackend = {
|
||||
findUnique: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const notificationServiceMock = vi.hoisted(() => ({
|
||||
@@ -15,6 +21,10 @@ const notificationServiceMock = vi.hoisted(() => ({
|
||||
sendToBackend: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
@@ -126,5 +136,62 @@ describe('Admin notifications test route', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('pushover', testConfig.config);
|
||||
});
|
||||
|
||||
it('tests existing backend using backend ID', async () => {
|
||||
const existingBackend = {
|
||||
id: 'backend-1',
|
||||
type: 'discord',
|
||||
name: 'Discord - Admins',
|
||||
config: { webhookUrl: 'iv:authTag:encryptedData', username: 'Bot' }, // Already encrypted
|
||||
events: ['request_available'],
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend);
|
||||
authRequest.json.mockResolvedValue({ backendId: 'backend-1' });
|
||||
notificationServiceMock.sendToBackend.mockResolvedValue(undefined);
|
||||
|
||||
const { POST } = await import('@/app/api/admin/notifications/test/route');
|
||||
const response = await POST({ json: authRequest.json } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.notificationBackend.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'backend-1' },
|
||||
});
|
||||
// Should use stored config directly, not encrypt again
|
||||
expect(notificationServiceMock.encryptConfig).not.toHaveBeenCalled();
|
||||
expect(notificationServiceMock.sendToBackend).toHaveBeenCalledWith(
|
||||
'discord',
|
||||
existingBackend.config,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 if backend ID not found', async () => {
|
||||
prismaMock.notificationBackend.findUnique.mockResolvedValue(null);
|
||||
authRequest.json.mockResolvedValue({ backendId: 'nonexistent' });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/notifications/test/route');
|
||||
const response = await POST({ json: authRequest.json } as any);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = await response.json();
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns validation error when payload is invalid', async () => {
|
||||
authRequest.json.mockResolvedValue('bad-payload');
|
||||
|
||||
const { POST } = await import('@/app/api/admin/notifications/test/route');
|
||||
const response = await POST({ json: authRequest.json } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
expect(payload.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,11 @@ const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const deleteRequestMock = vi.hoisted(() => vi.fn());
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addDownloadJob: vi.fn(),
|
||||
addSearchJob: vi.fn(),
|
||||
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -26,12 +31,17 @@ vi.mock('@/lib/services/request-delete.service', () => ({
|
||||
deleteRequest: deleteRequestMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
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());
|
||||
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('returns recent requests', async () => {
|
||||
@@ -73,6 +83,202 @@ describe('Admin requests routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(deleteRequestMock).toHaveBeenCalledWith('req-1', 'admin-1');
|
||||
});
|
||||
|
||||
it('returns 401 when admin user is missing', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 404 when delete service reports missing request', async () => {
|
||||
deleteRequestMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'NotFound',
|
||||
message: 'Missing',
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 500 when delete service fails', async () => {
|
||||
deleteRequestMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'DeleteFailed',
|
||||
message: 'boom',
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('DeleteFailed');
|
||||
});
|
||||
|
||||
it('returns pending approval requests', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([{ id: 'req-10', status: 'awaiting_approval' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.count).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 500 when pending approval fetch fails', async () => {
|
||||
prismaMock.request.findMany.mockRejectedValueOnce(new Error('fetch failed'));
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/pending-approval/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('FetchError');
|
||||
});
|
||||
|
||||
it('returns 401 when approving without a user', async () => {
|
||||
authRequest.user = null;
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns validation error for invalid approval action', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'maybe' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 404 when approving a missing request', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 400 when request is not awaiting approval', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u1', plexUsername: 'user' },
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('InvalidStatus');
|
||||
});
|
||||
|
||||
it('approves request with a selected torrent and triggers download', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: { title: 'Torrent' },
|
||||
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u3', plexUsername: 'user3' },
|
||||
userId: 'u3',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
status: 'downloading',
|
||||
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u3', plexUsername: 'user3' },
|
||||
userId: 'u3',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('approves request without a selected torrent and triggers search', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: null,
|
||||
audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' },
|
||||
user: { id: 'u4', plexUsername: 'user4' },
|
||||
userId: 'u4',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' },
|
||||
user: { id: 'u4', plexUsername: 'user4' },
|
||||
userId: 'u4',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies request without triggering jobs', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: null,
|
||||
audiobook: { id: 'ab-5', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u5', plexUsername: 'user5' },
|
||||
userId: 'u5',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
status: 'denied',
|
||||
audiobook: { id: 'ab-5', title: 'Title', author: 'Author' },
|
||||
user: { id: 'u5', plexUsername: 'user5' },
|
||||
userId: 'u5',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ action: 'deny' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'req-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -129,6 +129,100 @@ describe('Admin settings core routes', () => {
|
||||
expect(invalidateQbMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid download client types', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'transmission',
|
||||
url: 'http://transmission',
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid client type/);
|
||||
});
|
||||
|
||||
it('rejects missing qBittorrent credentials', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL, username, and password/);
|
||||
});
|
||||
|
||||
it('rejects missing SABnzbd credentials', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'sabnzbd',
|
||||
url: 'http://sab',
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/API key/);
|
||||
});
|
||||
|
||||
it('rejects path mapping when required fields are missing', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
remotePathMappingEnabled: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Remote path and local path/);
|
||||
});
|
||||
|
||||
it('rejects invalid path mapping configuration', async () => {
|
||||
pathMapperMock.validate.mockImplementationOnce(() => {
|
||||
throw new Error('bad mapping');
|
||||
});
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
remotePathMappingEnabled: true,
|
||||
remotePath: '/remote',
|
||||
localPath: '/local',
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/bad mapping/);
|
||||
});
|
||||
|
||||
it('updates paths settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -24,6 +24,7 @@ const qbtMock = vi.hoisted(() => ({
|
||||
const sabnzbdMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
}));
|
||||
const maskedValue = '\u2022\u2022\u2022\u2022';
|
||||
const testFlareSolverrMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
@@ -94,6 +95,40 @@ describe('Admin settings test routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects Plex test when URL or token is missing', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: '', token: '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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL and token are required/);
|
||||
});
|
||||
|
||||
it('rejects Plex test when masked token is missing in storage', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: maskedValue }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/No stored token/);
|
||||
});
|
||||
|
||||
it('returns error when Plex connection test fails', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValueOnce({ success: false, message: 'bad token' });
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: '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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/bad token/);
|
||||
});
|
||||
|
||||
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' }) };
|
||||
@@ -105,6 +140,40 @@ describe('Admin settings test routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects Prowlarr test when URL or API key is missing', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr' }) };
|
||||
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL and API key are required/);
|
||||
});
|
||||
|
||||
it('rejects masked Prowlarr API key when no stored key exists', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: maskedValue }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/No stored API key/);
|
||||
});
|
||||
|
||||
it('returns error when Prowlarr test fails', async () => {
|
||||
prowlarrMock.getIndexers.mockRejectedValueOnce(new Error('prowlarr down'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/prowlarr down/);
|
||||
});
|
||||
|
||||
it('tests download client connection', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
|
||||
const request = {
|
||||
@@ -252,6 +321,40 @@ describe('Admin settings test routes', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects FlareSolverr test when URL is missing', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({}) };
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL is required/);
|
||||
});
|
||||
|
||||
it('rejects FlareSolverr test when URL has invalid scheme', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'ftp://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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/must start with http/);
|
||||
});
|
||||
|
||||
it('returns error when FlareSolverr test throws', async () => {
|
||||
testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.message).toMatch(/flare down/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,105 @@ describe('Admin users routes', () => {
|
||||
expect(payload.user.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('rejects invalid roles', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'owner' }) };
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid role/);
|
||||
});
|
||||
|
||||
it('rejects invalid autoApproveRequests values', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'user', autoApproveRequests: 'yes' }) };
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/autoApproveRequests/);
|
||||
});
|
||||
|
||||
it('prevents changing your own role', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'user' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'admin-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/cannot change your own role/i);
|
||||
});
|
||||
|
||||
it('returns 404 when updating a missing user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
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: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/User not found/);
|
||||
});
|
||||
|
||||
it('prevents modifying deleted users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'user',
|
||||
deletedAt: new Date(),
|
||||
role: 'user',
|
||||
});
|
||||
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(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/deleted user/);
|
||||
});
|
||||
|
||||
it('prevents changing setup admin role', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: true,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'setup-admin',
|
||||
deletedAt: null,
|
||||
role: 'admin',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'user' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'setup-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/setup admin role/);
|
||||
});
|
||||
|
||||
it('rejects admin role with autoApproveRequests false', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'admin',
|
||||
deletedAt: null,
|
||||
role: 'admin',
|
||||
});
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'admin', autoApproveRequests: false }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'admin-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/auto-approve requests/);
|
||||
});
|
||||
|
||||
it('allows autoApproveRequests update for OIDC users without role change', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
@@ -151,6 +250,80 @@ describe('Admin users routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('prevents deleting yourself', async () => {
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'admin-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/delete your own account/);
|
||||
});
|
||||
|
||||
it('returns 404 when deleting a missing user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/User not found/);
|
||||
});
|
||||
|
||||
it('returns 400 when deleting an already deleted user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u4',
|
||||
plexUsername: 'user',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
deletedAt: new Date(),
|
||||
_count: { requests: 1 },
|
||||
});
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/already been deleted/);
|
||||
});
|
||||
|
||||
it('prevents deleting setup admin users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'setup-1',
|
||||
plexUsername: 'setup-admin',
|
||||
isSetupAdmin: true,
|
||||
authProvider: 'local',
|
||||
deletedAt: null,
|
||||
_count: { requests: 2 },
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'setup-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/setup admin/);
|
||||
});
|
||||
|
||||
it('prevents deleting non-local users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'plex-1',
|
||||
plexUsername: 'plexuser',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'plex',
|
||||
deletedAt: null,
|
||||
_count: { requests: 0 },
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'plex-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/Cannot delete Plex users/);
|
||||
});
|
||||
|
||||
it('approves a pending user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u5',
|
||||
@@ -166,6 +339,49 @@ describe('Admin users routes', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 when approving a missing user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
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: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('returns 400 when user is not pending approval', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u6',
|
||||
plexUsername: 'user',
|
||||
registrationStatus: 'approved',
|
||||
});
|
||||
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: 'u6' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/not pending/);
|
||||
});
|
||||
|
||||
it('rejects a pending user when approve is false', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u7',
|
||||
plexUsername: 'user',
|
||||
registrationStatus: 'pending_approval',
|
||||
});
|
||||
prismaMock.user.delete.mockResolvedValueOnce({});
|
||||
const request = { json: vi.fn().mockResolvedValue({ approve: false }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/users/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'u7' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toMatch(/rejected/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -94,6 +94,16 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.audiobooks[0].coverArtUrl).toBe('/api/cache/thumbnails/asin.jpg');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid new releases pagination', async () => {
|
||||
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
|
||||
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=0') } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns new release audiobooks', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(0);
|
||||
@@ -106,6 +116,54 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.count).toBe(0);
|
||||
});
|
||||
|
||||
it('enriches new releases and uses 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.2',
|
||||
genres: ['Fiction'],
|
||||
lastSyncedAt: new Date('2024-01-02'),
|
||||
},
|
||||
]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
|
||||
currentUserMock.mockReturnValue({ sub: 'user-1' });
|
||||
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]);
|
||||
|
||||
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(enrichMock).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
asin: 'ASIN',
|
||||
coverArtUrl: '/api/cache/thumbnails/asin.jpg',
|
||||
}),
|
||||
],
|
||||
'user-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 500 when new releases query fails', async () => {
|
||||
prismaMock.audibleCache.findMany.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('FetchError');
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -117,6 +175,38 @@ describe('Audiobooks browse routes', () => {
|
||||
expect(payload.audiobook.asin).toBe('ASIN123456');
|
||||
});
|
||||
|
||||
it('returns 400 when ASIN is invalid', async () => {
|
||||
const { GET } = await import('@/app/api/audiobooks/[asin]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ asin: 'BAD' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 404 when audiobook is not found', async () => {
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue(null);
|
||||
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(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 500 when audiobook lookup fails', async () => {
|
||||
audibleServiceMock.getAudiobookDetails.mockRejectedValue(new Error('fail'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('FetchError');
|
||||
});
|
||||
|
||||
it('returns cached covers for login', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null },
|
||||
|
||||
@@ -15,6 +15,9 @@ const jobQueueMock = vi.hoisted(() => ({
|
||||
addNotificationJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getAudiobookDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
@@ -33,9 +36,7 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => ({
|
||||
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||
}),
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
describe('Request with torrent route', () => {
|
||||
@@ -46,6 +47,7 @@ describe('Request with torrent route', () => {
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('returns 409 when audiobook is already being processed', async () => {
|
||||
@@ -68,6 +70,216 @@ describe('Request with torrent route', () => {
|
||||
expect(payload.error).toBe('BeingProcessed');
|
||||
});
|
||||
|
||||
it('returns 401 when user is missing', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
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(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 409 when audiobook is already available', 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: 'available',
|
||||
userId: 'user-2',
|
||||
user: { plexUsername: 'other' },
|
||||
} as any);
|
||||
|
||||
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('AlreadyAvailable');
|
||||
});
|
||||
|
||||
it('returns 409 when Plex match already exists', 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({ plexGuid: 'plex://item' });
|
||||
|
||||
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('AlreadyAvailable');
|
||||
expect(payload.plexGuid).toBe('plex://item');
|
||||
});
|
||||
|
||||
it('returns 409 for duplicate active requests', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-2', status: 'pending' } as any);
|
||||
|
||||
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('DuplicateRequest');
|
||||
});
|
||||
|
||||
it('deletes failed requests before creating a new one', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-old', status: 'failed' } as any);
|
||||
prismaMock.request.delete.mockResolvedValueOnce({} as any);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
plexUsername: 'user',
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
} as any);
|
||||
|
||||
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(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-old' } });
|
||||
});
|
||||
|
||||
it('returns 404 when user lookup fails', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
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(404);
|
||||
expect(payload.error).toBe('UserNotFound');
|
||||
});
|
||||
|
||||
it('stores selected torrent when approval is required', 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({ id: 'ab-1', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: false,
|
||||
plexUsername: 'user',
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
} as any);
|
||||
|
||||
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(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_approval',
|
||||
selectedTorrent: expect.objectContaining({ guid: 'guid' }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
||||
'request_pending_approval',
|
||||
'req-4',
|
||||
'Title',
|
||||
'Author',
|
||||
'user'
|
||||
);
|
||||
});
|
||||
|
||||
it('updates year from Audnexus when audiobook already exists', 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);
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({ releaseDate: '2020-01-02' });
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce({ id: 'ab-2', title: 'Title', author: 'Author' } as any);
|
||||
prismaMock.audiobook.update.mockResolvedValueOnce({ id: 'ab-2', year: 2020 } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'admin',
|
||||
autoApproveRequests: null,
|
||||
plexUsername: 'user',
|
||||
} as any);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
} as any);
|
||||
|
||||
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(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ year: 2020 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns validation errors for invalid payloads', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { title: 'Missing fields' },
|
||||
});
|
||||
|
||||
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(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('creates request and queues download job', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' },
|
||||
@@ -104,4 +316,3 @@ describe('Request with torrent route', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,50 @@ describe('Auth misc routes', () => {
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('returns 400 when refresh token is missing', async () => {
|
||||
const { POST } = await import('@/app/api/auth/refresh/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({}) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 401 when refresh token is invalid', async () => {
|
||||
verifyRefreshTokenMock.mockReturnValue(null);
|
||||
const { POST } = await import('@/app/api/auth/refresh/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'bad' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 401 when user is not found for refresh token', async () => {
|
||||
verifyRefreshTokenMock.mockReturnValue({ sub: 'user-missing' });
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
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(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 500 when refresh token verification throws', async () => {
|
||||
verifyRefreshTokenMock.mockImplementation(() => {
|
||||
throw new Error('bad 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(500);
|
||||
expect(payload.error).toBe('RefreshError');
|
||||
});
|
||||
|
||||
it('returns provider info for audiobookshelf mode', async () => {
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce('audiobookshelf')
|
||||
|
||||
@@ -38,6 +38,27 @@ describe('OIDC auth routes', () => {
|
||||
expect(response.headers.get('location')).toBe('http://oidc/login');
|
||||
});
|
||||
|
||||
it('returns error when OIDC login URL is missing', async () => {
|
||||
authProviderMock.initiateLogin.mockResolvedValue({});
|
||||
const { GET } = await import('@/app/api/auth/oidc/login/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to generate authorization URL/);
|
||||
});
|
||||
|
||||
it('redirects to login when OIDC login initiation fails', async () => {
|
||||
authProviderMock.initiateLogin.mockRejectedValue(new Error('boom'));
|
||||
const { GET } = await import('@/app/api/auth/oidc/login/route');
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get('location')).toContain('/login?error=');
|
||||
});
|
||||
|
||||
it('redirects to login on missing code/state', async () => {
|
||||
const { GET } = await import('@/app/api/auth/oidc/callback/route');
|
||||
|
||||
|
||||
@@ -278,6 +278,64 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects Plex home users when token is missing', async () => {
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 500 when Plex home users fetch fails', async () => {
|
||||
plexServiceMock.getHomeUsers.mockRejectedValue(new Error('boom'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toBe('ServerError');
|
||||
});
|
||||
|
||||
it('rejects profile switch without main account token', async () => {
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile');
|
||||
request.json.mockResolvedValue({ userId: 'home-1' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects profile switch when userId is missing', async () => {
|
||||
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({});
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 401 for invalid profile PIN', async () => {
|
||||
plexServiceMock.switchHomeUser.mockRejectedValue(new Error('Invalid PIN'));
|
||||
|
||||
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: '0000' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('InvalidPIN');
|
||||
});
|
||||
|
||||
it('switches Plex profile using provided profile info', async () => {
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
@@ -304,6 +362,51 @@ describe('Plex auth routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('switches Plex profile using getUserInfo fallback', async () => {
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 'plex-3',
|
||||
username: 'Fallback',
|
||||
email: 'user@example.com',
|
||||
thumb: '/avatar',
|
||||
});
|
||||
prismaMock.user.count.mockResolvedValue(0);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-3',
|
||||
plexId: 'plex-3',
|
||||
plexUsername: 'Fallback',
|
||||
plexEmail: 'user@example.com',
|
||||
role: 'admin',
|
||||
avatarUrl: '/avatar',
|
||||
});
|
||||
|
||||
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-2', pin: '1234' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.user.plexId).toBe('plex-3');
|
||||
expect(payload.user.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns 500 when profile info lookup fails', async () => {
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 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-2', pin: '1234' });
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('ServerError');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Component: BookDate Library Route Tests
|
||||
* Documentation: documentation/features/bookdate.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 configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
describe('BookDate library route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'user-1' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 400 when Audiobookshelf library ID is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Audiobookshelf library ID/i);
|
||||
});
|
||||
|
||||
it('returns 400 when Plex library ID is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Plex library ID/i);
|
||||
});
|
||||
|
||||
it('returns books with cover priority (library cache > audible cache > null)', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'lib-1' });
|
||||
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'book-1',
|
||||
title: 'Cached Cover',
|
||||
author: 'Author A',
|
||||
asin: 'ASIN1',
|
||||
cachedLibraryCoverPath: '/cache/library/cover1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'book-2',
|
||||
title: 'Audible Cover',
|
||||
author: 'Author B',
|
||||
asin: 'ASIN2',
|
||||
cachedLibraryCoverPath: null,
|
||||
},
|
||||
{
|
||||
id: 'book-3',
|
||||
title: 'No Cover',
|
||||
author: 'Author C',
|
||||
asin: null,
|
||||
cachedLibraryCoverPath: null,
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.audibleCache.findMany.mockResolvedValue([
|
||||
{ asin: 'ASIN1', coverArtUrl: 'http://audible/cover1.jpg' },
|
||||
{ asin: 'ASIN2', coverArtUrl: 'http://audible/cover2.jpg' },
|
||||
]);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.books).toEqual([
|
||||
{
|
||||
id: 'book-1',
|
||||
title: 'Cached Cover',
|
||||
author: 'Author A',
|
||||
coverUrl: '/api/cache/library/cover1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'book-2',
|
||||
title: 'Audible Cover',
|
||||
author: 'Author B',
|
||||
coverUrl: 'http://audible/cover2.jpg',
|
||||
},
|
||||
{
|
||||
id: 'book-3',
|
||||
title: 'No Cover',
|
||||
author: 'Author C',
|
||||
coverUrl: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 500 when database lookup fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'lib-1' });
|
||||
prismaMock.plexLibrary.findMany.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/library/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/db down/i);
|
||||
});
|
||||
});
|
||||
@@ -355,6 +355,61 @@ describe('BookDate routes', () => {
|
||||
expect(payload.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 500 when AI response is missing recommendations', 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({ invalid: true });
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/recommendations/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Invalid AI response format/);
|
||||
});
|
||||
|
||||
it('returns generated response even when no valid recommendations match', 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 only' }, { author: 'Author only' }],
|
||||
});
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
|
||||
(prismaMock.bookDateRecommendation as any).createMany = vi.fn();
|
||||
|
||||
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(payload.generatedCount).toBe(0);
|
||||
expect((prismaMock.bookDateRecommendation as any).createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when generating recommendations without config', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
@@ -395,6 +450,49 @@ describe('BookDate routes', () => {
|
||||
expect(payload.error).toMatch(/Could not find any new recommendations/i);
|
||||
});
|
||||
|
||||
it('returns 404 when user is missing during generation', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: null,
|
||||
});
|
||||
prismaMock.user.findUnique.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(/User not found/);
|
||||
});
|
||||
|
||||
it('returns 500 when generate receives invalid AI response', 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({ invalid: true });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/generate/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Invalid AI response format/);
|
||||
});
|
||||
|
||||
it('stores generated recommendations from the AI', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
@@ -436,6 +534,112 @@ describe('BookDate routes', () => {
|
||||
expect(payload.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 400 when swipe payload is missing fields', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'left' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/recommendationId and action/);
|
||||
});
|
||||
|
||||
it('returns 400 when swipe action is invalid', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'sideways' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid action/);
|
||||
});
|
||||
|
||||
it('returns 404 when recommendation is missing', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'left' });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/Recommendation not found/);
|
||||
});
|
||||
|
||||
it('does not create a request when right swipe is marked as known', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-known', action: 'right', markedAsKnown: true });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
id: 'rec-known',
|
||||
userId: 'user-1',
|
||||
title: 'Known Book',
|
||||
author: 'Known Author',
|
||||
audnexusAsin: 'ASIN-KNOWN',
|
||||
} as any);
|
||||
|
||||
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(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records left swipe without creating a request', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-left', action: 'left' });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
id: 'rec-left',
|
||||
userId: 'user-1',
|
||||
title: 'Left Book',
|
||||
author: 'Left Author',
|
||||
audnexusAsin: 'ASIN-LEFT',
|
||||
} as any);
|
||||
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
|
||||
|
||||
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(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates existing audiobook year when Audnexus provides releaseDate', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-year', action: 'right', markedAsKnown: false });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
id: 'rec-year',
|
||||
userId: 'user-1',
|
||||
title: 'Year Book',
|
||||
author: 'Year Author',
|
||||
audnexusAsin: 'ASIN-YEAR',
|
||||
} as any);
|
||||
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
|
||||
releaseDate: '2020-01-15',
|
||||
});
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce({
|
||||
id: 'ab-year',
|
||||
title: 'Year Book',
|
||||
author: 'Year Author',
|
||||
audibleAsin: 'ASIN-YEAR',
|
||||
} as any);
|
||||
prismaMock.audiobook.update.mockResolvedValueOnce({ id: 'ab-year' } as any);
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-existing' } as any);
|
||||
|
||||
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(prismaMock.audiobook.update).toHaveBeenCalledWith({
|
||||
where: { id: 'ab-year' },
|
||||
data: { year: 2020 },
|
||||
});
|
||||
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records swipe and creates request on right swipe (admin auto-approves)', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
@@ -665,4 +869,3 @@ describe('BookDate routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,40 @@ describe('Config API routes', () => {
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when configuration update payload is invalid', async () => {
|
||||
const { PUT } = await import('@/app/api/config/route');
|
||||
const response = await PUT({ json: vi.fn().mockResolvedValue({}) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Validation error/);
|
||||
});
|
||||
|
||||
it('returns 500 when configuration update fails', async () => {
|
||||
configServiceMock.setMany.mockRejectedValueOnce(new Error('db down'));
|
||||
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(500);
|
||||
expect(payload.error).toMatch(/Failed to update configuration/);
|
||||
});
|
||||
|
||||
it('returns 500 when configuration lookup fails', async () => {
|
||||
configServiceMock.getAll.mockRejectedValueOnce(new Error('db down'));
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to get configuration/);
|
||||
});
|
||||
|
||||
it('returns category configuration', async () => {
|
||||
configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' });
|
||||
const { GET } = await import('@/app/api/config/[category]/route');
|
||||
@@ -54,6 +88,17 @@ describe('Config API routes', () => {
|
||||
expect(payload.category).toBe('plex');
|
||||
expect(payload.config.plex_url).toBe('http://plex');
|
||||
});
|
||||
|
||||
it('returns 500 when category configuration lookup fails', async () => {
|
||||
configServiceMock.getCategory.mockRejectedValueOnce(new Error('db down'));
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to get configuration/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,60 @@ describe('Request action routes', () => {
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when manual search user is not authenticated', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-auth' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 404 when manual search request is missing', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/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).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 403 when manual search request is not owned', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-9',
|
||||
userId: 'user-2',
|
||||
status: 'failed',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-9' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('returns 400 when manual search status is not eligible', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-10',
|
||||
userId: 'user-1',
|
||||
status: 'downloading',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-10' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('selects a torrent and queues download', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
@@ -134,6 +188,134 @@ describe('Request action routes', () => {
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when user is not authenticated', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-auth' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 400 when torrent data is missing', async () => {
|
||||
authRequest.json.mockResolvedValue({});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing-torrent' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 404 when request is not found', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/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).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 403 when user does not own the request', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
userId: 'user-2',
|
||||
status: 'awaiting_search',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('returns 403 when request is awaiting approval', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_approval',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('AwaitingApproval');
|
||||
});
|
||||
|
||||
it('stores selected torrent when approval is required by global setting', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
configState.values.set('auto_approve_requests', 'false');
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_search',
|
||||
audiobook: { id: 'ab-3', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: null,
|
||||
plexUsername: 'plexuser',
|
||||
} as any);
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
status: 'awaiting_approval',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toMatch(/approval/i);
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-approves when global setting is missing and user has no preference', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_search',
|
||||
audiobook: { id: 'ab-4', title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
autoApproveRequests: null,
|
||||
plexUsername: 'plexuser',
|
||||
} as any);
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
status: 'downloading',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
} as any);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-7' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when ebook sidecar is disabled', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Setup Status API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
describe('Setup status route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns true when setup_completed is true', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce({
|
||||
key: 'setup_completed',
|
||||
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('returns false when setup_completed is missing', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { GET } = await import('@/app/api/setup/status/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.setupComplete).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the database lookup fails', async () => {
|
||||
prismaMock.configuration.findUnique.mockRejectedValueOnce(new Error('db not ready'));
|
||||
|
||||
const { GET } = await import('@/app/api/setup/status/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.setupComplete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,55 @@ describe('Setup test routes', () => {
|
||||
expect(payload.libraries[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('returns 400 when Plex url or token is missing', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-plex/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/URL and token/);
|
||||
});
|
||||
|
||||
it('returns 400 when Plex connection fails', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'bad token',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-plex/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'bad' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/bad token/);
|
||||
});
|
||||
|
||||
it('returns 400 when Plex info is missing', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValue({
|
||||
success: true,
|
||||
info: null,
|
||||
message: 'missing info',
|
||||
});
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/missing info/);
|
||||
});
|
||||
|
||||
it('returns 500 when Plex test throws', async () => {
|
||||
plexServiceMock.testConnection.mockRejectedValue(new Error('connection error'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/connection error/);
|
||||
});
|
||||
|
||||
it('tests qBittorrent credentials', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0');
|
||||
|
||||
@@ -103,6 +152,28 @@ describe('Setup test routes', () => {
|
||||
expect(payload.version).toBe('4.0.0');
|
||||
});
|
||||
|
||||
it('rejects invalid download client type', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-download-client/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ type: 'transmission', url: 'http://transmission' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid client type/);
|
||||
});
|
||||
|
||||
it('rejects missing qBittorrent credentials', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-download-client/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Username and password/);
|
||||
});
|
||||
|
||||
it('tests SABnzbd connection', async () => {
|
||||
sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' });
|
||||
|
||||
@@ -120,6 +191,23 @@ describe('Setup test routes', () => {
|
||||
expect(payload.version).toBe('3.0');
|
||||
});
|
||||
|
||||
it('returns error when SABnzbd connection fails', async () => {
|
||||
sabnzbdMock.testConnection.mockResolvedValue({ success: false, error: 'bad key' });
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/bad key/);
|
||||
});
|
||||
|
||||
it('tests Prowlarr indexers', async () => {
|
||||
prowlarrMock.getIndexers.mockResolvedValue([
|
||||
{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true, capabilities: {} },
|
||||
@@ -161,6 +249,72 @@ describe('Setup test routes', () => {
|
||||
expect(payload.issuer.authorizationEndpoint).toBe('http://issuer/auth');
|
||||
});
|
||||
|
||||
it('returns error when OIDC fields are missing', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-oidc/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ issuerUrl: 'http://issuer', clientId: 'client' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/required/);
|
||||
});
|
||||
|
||||
it('returns error when OIDC issuer URL is invalid', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-oidc/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
issuerUrl: 'not a url',
|
||||
clientId: 'client',
|
||||
clientSecret: 'secret',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid issuer URL/);
|
||||
});
|
||||
|
||||
it('returns error when OIDC issuer metadata is incomplete', async () => {
|
||||
issuerMock.discover.mockResolvedValue({
|
||||
issuer: 'http://issuer',
|
||||
metadata: {
|
||||
token_endpoint: 'http://issuer/token',
|
||||
userinfo_endpoint: 'http://issuer/user',
|
||||
},
|
||||
});
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/missing required endpoints/);
|
||||
});
|
||||
|
||||
it('returns friendly error when OIDC discovery fails to resolve host', async () => {
|
||||
issuerMock.discover.mockRejectedValue(new Error('getaddrinfo ENOTFOUND issuer'));
|
||||
|
||||
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(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Cannot reach OIDC provider/);
|
||||
});
|
||||
|
||||
it('validates paths are writable', async () => {
|
||||
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
||||
fsMock.mkdir.mockResolvedValueOnce(undefined);
|
||||
@@ -280,6 +434,63 @@ describe('Setup test routes', () => {
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.libraries[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('returns error when Audiobookshelf server URL is missing', async () => {
|
||||
const { POST } = await import('@/app/api/setup/test-abs/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({}) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Server URL/);
|
||||
});
|
||||
|
||||
it('returns error when saved Audiobookshelf token is missing', async () => {
|
||||
configServiceMock.get.mockResolvedValueOnce(null);
|
||||
|
||||
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(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/API token is required/);
|
||||
});
|
||||
|
||||
it('returns error when Audiobookshelf connection fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
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: 'token' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Connection failed/);
|
||||
});
|
||||
|
||||
it('returns error when Audiobookshelf response is missing libraries', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
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: 'token' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid response/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Component: Admin Settings Page Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import path from 'path';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
const saveTabSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const getTabValidationMock = vi.hoisted(() => vi.fn());
|
||||
const getTabsMock = vi.hoisted(() => vi.fn());
|
||||
const parseArrayToCommaSeparatedMock = vi.hoisted(() => vi.fn((value: string) => value));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const mockAdminSettingsModules = () => {
|
||||
vi.doMock(path.resolve('src/app/admin/settings/lib/helpers.ts'), () => ({
|
||||
parseArrayToCommaSeparated: parseArrayToCommaSeparatedMock,
|
||||
saveTabSettings: saveTabSettingsMock,
|
||||
validateAuthSettings: vi.fn(() => ({ valid: true })),
|
||||
getTabValidation: getTabValidationMock,
|
||||
getTabs: getTabsMock,
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/LibraryTab/LibraryTab.tsx'), () => ({
|
||||
LibraryTab: ({ settings, onChange }: { settings: any; onChange: (next: any) => void }) => (
|
||||
<div>
|
||||
<div>Library Tab</div>
|
||||
<button type="button" onClick={() => onChange({ ...settings, audibleRegion: 'uk' })}>
|
||||
Change Settings
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/AuthTab/AuthTab.tsx'), () => ({
|
||||
AuthTab: () => <div>Auth Tab</div>,
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx'), () => ({
|
||||
IndexersTab: () => <div>Indexers Tab</div>,
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx'), () => ({
|
||||
DownloadTab: () => <div>Download Tab</div>,
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/PathsTab/PathsTab.tsx'), () => ({
|
||||
PathsTab: () => <div>Paths Tab</div>,
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/EbookTab/EbookTab.tsx'), () => ({
|
||||
EbookTab: () => <div>Ebook Tab</div>,
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx'), () => ({
|
||||
BookDateTab: () => <div>BookDate Tab</div>,
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/settings/tabs/NotificationsTab/index.tsx'), () => ({
|
||||
NotificationsTab: () => <div>Notifications Tab</div>,
|
||||
}));
|
||||
};
|
||||
|
||||
const settingsFixture = {
|
||||
backendMode: 'plex',
|
||||
hasLocalUsers: true,
|
||||
audibleRegion: 'us',
|
||||
plex: { url: '', token: '', libraryId: '', triggerScanAfterImport: false },
|
||||
audiobookshelf: { serverUrl: '', apiToken: '', libraryId: '', triggerScanAfterImport: false },
|
||||
oidc: {
|
||||
enabled: false,
|
||||
providerName: '',
|
||||
issuerUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
accessControlMethod: 'open',
|
||||
accessGroupClaim: 'groups',
|
||||
accessGroupValue: '',
|
||||
allowedEmails: '[]',
|
||||
allowedUsernames: '[]',
|
||||
adminClaimEnabled: false,
|
||||
adminClaimName: 'groups',
|
||||
adminClaimValue: '',
|
||||
},
|
||||
registration: { enabled: false, requireAdminApproval: false },
|
||||
prowlarr: { url: '', apiKey: '' },
|
||||
downloadClient: {
|
||||
type: 'qbittorrent',
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
},
|
||||
paths: {
|
||||
downloadDir: '',
|
||||
mediaDir: '',
|
||||
audiobookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' },
|
||||
};
|
||||
|
||||
describe('AdminSettings', () => {
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
saveTabSettingsMock.mockReset();
|
||||
getTabValidationMock.mockReset();
|
||||
getTabsMock.mockReset();
|
||||
parseArrayToCommaSeparatedMock.mockReset();
|
||||
vi.resetModules();
|
||||
mockAdminSettingsModules();
|
||||
});
|
||||
|
||||
it('fetches settings and renders the settings shell', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => settingsFixture,
|
||||
});
|
||||
getTabValidationMock.mockReturnValue(true);
|
||||
getTabsMock.mockReturnValue([{ id: 'library', label: 'Library', icon: 'L' }]);
|
||||
|
||||
const { default: AdminSettings } = await import('@/app/admin/settings/page');
|
||||
render(<AdminSettings />);
|
||||
|
||||
expect(await screen.findByText('Settings')).toBeInTheDocument();
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/settings');
|
||||
});
|
||||
|
||||
it('saves settings when changes are made and validation passes', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => settingsFixture,
|
||||
});
|
||||
getTabValidationMock.mockReturnValue(true);
|
||||
getTabsMock.mockReturnValue([{ id: 'library', label: 'Library', icon: 'L' }]);
|
||||
|
||||
const { default: AdminSettings } = await import('@/app/admin/settings/page');
|
||||
render(<AdminSettings />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Change Settings' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Save Settings' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveTabSettingsMock).toHaveBeenCalledWith(
|
||||
'library',
|
||||
expect.objectContaining({ audibleRegion: 'uk' }),
|
||||
[],
|
||||
[]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Component: Admin Users Page Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AdminUsersPage from '@/app/admin/users/page';
|
||||
|
||||
const fetchJSONMock = vi.hoisted(() => vi.fn());
|
||||
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
const swrState = new Map<string, { data?: any; error?: any; mutate: ReturnType<typeof vi.fn> }>();
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: (key: string) => {
|
||||
return swrState.get(key) || { data: undefined, error: undefined, mutate: vi.fn() };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
authenticatedFetcher: authenticatedFetcherMock,
|
||||
fetchJSON: fetchJSONMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
describe('AdminUsersPage', () => {
|
||||
beforeEach(() => {
|
||||
swrState.clear();
|
||||
fetchJSONMock.mockReset();
|
||||
toastMock.success.mockReset();
|
||||
toastMock.error.mockReset();
|
||||
});
|
||||
|
||||
it('toggles global auto-approve and persists setting', async () => {
|
||||
const mutateUsers = vi.fn();
|
||||
const mutatePending = vi.fn();
|
||||
const mutateGlobal = vi.fn();
|
||||
|
||||
swrState.set('/api/admin/users', {
|
||||
data: { users: [{ id: 'u1', plexUsername: 'User', plexId: 'plex-1', role: 'user', isSetupAdmin: false, authProvider: 'local', plexEmail: null, avatarUrl: null, createdAt: '', updatedAt: '', lastLoginAt: null, autoApproveRequests: false, _count: { requests: 0 } }] },
|
||||
mutate: mutateUsers,
|
||||
});
|
||||
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: mutatePending });
|
||||
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: false }, mutate: mutateGlobal });
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Auto-Approve All Requests'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/settings/auto-approve', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ autoApproveRequests: true }),
|
||||
});
|
||||
expect(mutateGlobal).toHaveBeenCalled();
|
||||
expect(mutateUsers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('edits a user role and saves changes', async () => {
|
||||
const mutateUsers = vi.fn();
|
||||
|
||||
swrState.set('/api/admin/users', {
|
||||
data: {
|
||||
users: [
|
||||
{
|
||||
id: 'u2',
|
||||
plexUsername: 'LocalUser',
|
||||
plexId: 'local-1',
|
||||
role: 'user',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexEmail: 'local@example.com',
|
||||
avatarUrl: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
lastLoginAt: null,
|
||||
autoApproveRequests: false,
|
||||
_count: { requests: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
mutate: mutateUsers,
|
||||
});
|
||||
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: vi.fn() });
|
||||
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit Role' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Admin/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u2', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role: 'admin' }),
|
||||
});
|
||||
expect(mutateUsers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('approves a pending user and refreshes lists', async () => {
|
||||
const mutateUsers = vi.fn();
|
||||
const mutatePending = vi.fn();
|
||||
|
||||
swrState.set('/api/admin/users', { data: { users: [] }, mutate: mutateUsers });
|
||||
swrState.set('/api/admin/users/pending', {
|
||||
data: {
|
||||
users: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }],
|
||||
},
|
||||
mutate: mutatePending,
|
||||
});
|
||||
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
const approveButtons = await screen.findAllByRole('button', { name: 'Approve' });
|
||||
fireEvent.click(approveButtons[0]);
|
||||
|
||||
const confirmButtons = await screen.findAllByRole('button', { name: 'Approve' });
|
||||
fireEvent.click(confirmButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/p1/approve', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ approve: true }),
|
||||
});
|
||||
expect(mutatePending).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Component: BookDate Page Tests
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockRouter, routerMock } from '../helpers/mock-next-navigation';
|
||||
|
||||
vi.mock('@/components/layout/Header', () => ({
|
||||
Header: () => <div data-testid="header" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/bookdate/LoadingScreen', () => ({
|
||||
LoadingScreen: () => <div data-testid="loading" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/bookdate/SettingsWidget', () => ({
|
||||
SettingsWidget: ({
|
||||
isOpen,
|
||||
isOnboarding,
|
||||
onOnboardingComplete,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
isOnboarding: boolean;
|
||||
onOnboardingComplete: () => void;
|
||||
}) => (
|
||||
<div data-testid="settings-widget" data-open={String(isOpen)} data-onboarding={String(isOnboarding)}>
|
||||
<button type="button" onClick={onOnboardingComplete}>
|
||||
Finish Onboarding
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/bookdate/CardStack', () => ({
|
||||
CardStack: ({
|
||||
recommendations,
|
||||
onSwipe,
|
||||
onSwipeComplete,
|
||||
}: {
|
||||
recommendations: any[];
|
||||
onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
|
||||
onSwipeComplete: () => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="card-count">{recommendations.length}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSwipe('left');
|
||||
onSwipeComplete();
|
||||
}}
|
||||
>
|
||||
Swipe Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSwipe('right');
|
||||
onSwipeComplete();
|
||||
}}
|
||||
>
|
||||
Swipe Right
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const makeJsonResponse = (body: any, ok: boolean = true) => ({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('BookDatePage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockRouter();
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('redirects to login when no access token is available', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: BookDatePage } = await import('@/app/bookdate/page');
|
||||
render(<BookDatePage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows onboarding settings when onboarding is incomplete', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/bookdate/preferences') {
|
||||
return makeJsonResponse({ onboardingComplete: false });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: BookDatePage } = await import('@/app/bookdate/page');
|
||||
render(<BookDatePage />);
|
||||
|
||||
expect(await screen.findByText('Welcome to BookDate!')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('renders an error state when recommendations fetch fails', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/bookdate/preferences') {
|
||||
return makeJsonResponse({ onboardingComplete: true });
|
||||
}
|
||||
if (url === '/api/bookdate/recommendations') {
|
||||
return makeJsonResponse({ error: 'bad' }, false);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: BookDatePage } = await import('@/app/bookdate/page');
|
||||
render(<BookDatePage />);
|
||||
|
||||
expect(await screen.findByText(/Could not load recommendations/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try Again' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const recCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes('/api/bookdate/recommendations'));
|
||||
expect(recCalls.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state and triggers recommendation generation', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/bookdate/preferences') {
|
||||
return makeJsonResponse({ onboardingComplete: true });
|
||||
}
|
||||
if (url === '/api/bookdate/recommendations') {
|
||||
return makeJsonResponse({ recommendations: [] });
|
||||
}
|
||||
if (url === '/api/bookdate/generate') {
|
||||
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: BookDatePage } = await import('@/app/bookdate/page');
|
||||
render(<BookDatePage />);
|
||||
|
||||
const generateButton = await screen.findByRole('button', { name: 'Get More Recommendations' });
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/bookdate/generate',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('posts swipes and shows undo option', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/bookdate/preferences') {
|
||||
return makeJsonResponse({ onboardingComplete: true });
|
||||
}
|
||||
if (url === '/api/bookdate/recommendations') {
|
||||
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] });
|
||||
}
|
||||
if (url === '/api/bookdate/swipe') {
|
||||
return makeJsonResponse({ success: true });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: BookDatePage } = await import('@/app/bookdate/page');
|
||||
render(<BookDatePage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/bookdate/swipe',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Undo/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens settings when the settings button is clicked', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/bookdate/preferences') {
|
||||
return makeJsonResponse({ onboardingComplete: true });
|
||||
}
|
||||
if (url === '/api/bookdate/recommendations') {
|
||||
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: BookDatePage } = await import('@/app/bookdate/page');
|
||||
render(<BookDatePage />);
|
||||
|
||||
expect(await screen.findByTestId('card-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'false');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open settings' }));
|
||||
|
||||
expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('undoes a swipe and reloads recommendations', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
let recommendationsCall = 0;
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/bookdate/preferences') {
|
||||
return makeJsonResponse({ onboardingComplete: true });
|
||||
}
|
||||
if (url === '/api/bookdate/recommendations') {
|
||||
recommendationsCall += 1;
|
||||
if (recommendationsCall === 1) {
|
||||
return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] });
|
||||
}
|
||||
return makeJsonResponse({ recommendations: [{ id: 'rec-restored' }] });
|
||||
}
|
||||
if (url === '/api/bookdate/swipe') {
|
||||
return makeJsonResponse({ success: true });
|
||||
}
|
||||
if (url === '/api/bookdate/undo') {
|
||||
return makeJsonResponse({ success: true });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: BookDatePage } = await import('@/app/bookdate/page');
|
||||
render(<BookDatePage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' }));
|
||||
|
||||
const undoButton = await screen.findByRole('button', { name: /Undo/i });
|
||||
fireEvent.click(undoButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/bookdate/undo',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('card-count')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /Undo/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Component: Home Page Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const useAudiobooksMock = vi.hoisted(() => vi.fn());
|
||||
const usePreferencesMock = vi.hoisted(() => ({
|
||||
cardSize: 5,
|
||||
setCardSize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
||||
useAudiobooks: useAudiobooksMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/contexts/PreferencesContext', () => ({
|
||||
usePreferences: () => usePreferencesMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/auth/ProtectedRoute', () => ({
|
||||
ProtectedRoute: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/Header', () => ({
|
||||
Header: () => <div data-testid="header" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
|
||||
AudiobookGrid: ({ audiobooks, cardSize }: { audiobooks: any[]; cardSize?: number }) => (
|
||||
<div data-testid="grid" data-count={audiobooks.length} data-size={cardSize}>
|
||||
{audiobooks.map((book) => (
|
||||
<div key={book.asin}>{book.title}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/CardSizeControls', () => ({
|
||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/StickyPagination', () => ({
|
||||
StickyPagination: ({
|
||||
label,
|
||||
onPageChange,
|
||||
}: {
|
||||
label: string;
|
||||
onPageChange: (page: number) => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onPageChange(2)}>
|
||||
{label} next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
useAudiobooksMock.mockReset();
|
||||
usePreferencesMock.cardSize = 5;
|
||||
usePreferencesMock.setCardSize.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('renders empty state messaging for popular audiobooks', async () => {
|
||||
useAudiobooksMock.mockImplementation((category: string) => {
|
||||
if (category === 'popular') {
|
||||
return {
|
||||
audiobooks: [],
|
||||
isLoading: false,
|
||||
totalPages: 1,
|
||||
message: 'Nothing here',
|
||||
};
|
||||
}
|
||||
return {
|
||||
audiobooks: [{ asin: 'n1', title: 'New Release', author: 'Author' }],
|
||||
isLoading: false,
|
||||
totalPages: 2,
|
||||
message: null,
|
||||
};
|
||||
});
|
||||
|
||||
const { default: HomePage } = await import('@/app/page');
|
||||
render(<HomePage />);
|
||||
|
||||
expect(screen.getByText('No popular audiobooks found')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
expect(screen.getByText('New Release')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates pagination when the sticky controls request a new page', async () => {
|
||||
useAudiobooksMock.mockImplementation((category: string, _limit: number, page: number) => {
|
||||
return {
|
||||
audiobooks: [{ asin: `${category}-${page}`, title: `${category}-${page}`, author: 'Author' }],
|
||||
isLoading: false,
|
||||
totalPages: 3,
|
||||
message: null,
|
||||
};
|
||||
});
|
||||
|
||||
const { default: HomePage } = await import('@/app/page');
|
||||
render(<HomePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* Component: Login Page Tests
|
||||
* Documentation: documentation/frontend/pages/login.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation';
|
||||
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
|
||||
|
||||
const makeJsonResponse = (body: any, ok: boolean = true) => ({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const baseProviders = {
|
||||
backendMode: 'plex',
|
||||
providers: ['plex'],
|
||||
registrationEnabled: false,
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled: false,
|
||||
automationEnabled: false,
|
||||
};
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockRouter();
|
||||
resetMockAuthState();
|
||||
localStorage.clear();
|
||||
setMockSearchParams('');
|
||||
window.innerWidth = 1024;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders description based on backend mode and automation flag', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') {
|
||||
return makeJsonResponse({
|
||||
...baseProviders,
|
||||
backendMode: 'audiobookshelf',
|
||||
automationEnabled: true,
|
||||
});
|
||||
}
|
||||
if (url === '/api/audiobooks/covers') {
|
||||
return makeJsonResponse({ success: true, covers: [] });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Request audiobooks and they'll automatically download and appear in your Audiobookshelf library"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to intended page when user is already logged in', async () => {
|
||||
setMockAuthState({
|
||||
user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' },
|
||||
isLoading: false,
|
||||
});
|
||||
setMockSearchParams('redirect=/requests');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/requests');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Plex login with popup flow', async () => {
|
||||
const loginMock = vi.fn().mockResolvedValue(undefined);
|
||||
setMockAuthState({ login: loginMock, isLoading: false });
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
if (url === '/api/auth/plex/login') {
|
||||
return makeJsonResponse({ pinId: 123, authUrl: 'http://plex/auth' });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const closeMock = vi.fn();
|
||||
const openMock = vi.fn().mockReturnValue({ close: closeMock });
|
||||
vi.stubGlobal('open', openMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginMock).toHaveBeenCalledWith(123);
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
'http://plex/auth',
|
||||
'plex-auth',
|
||||
'width=600,height=700,scrollbars=yes,resizable=yes'
|
||||
);
|
||||
expect(closeMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error when Plex login popup is blocked', async () => {
|
||||
const loginMock = vi.fn().mockResolvedValue(undefined);
|
||||
setMockAuthState({ login: loginMock, isLoading: false });
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
if (url === '/api/auth/plex/login') {
|
||||
return makeJsonResponse({ pinId: 456, authUrl: 'http://plex/auth' });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('open', vi.fn().mockReturnValue(null));
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
expect(await screen.findByText(/Popup was blocked/i)).toBeInTheDocument();
|
||||
expect(loginMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs in with local credentials and stores tokens', async () => {
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
|
||||
const providers = {
|
||||
...baseProviders,
|
||||
providers: ['local'],
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
if (url === '/api/auth/local/login') {
|
||||
return makeJsonResponse({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
user: { id: 'user-1', username: 'local-user', role: 'admin' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
const username = await screen.findByLabelText('Username');
|
||||
const password = screen.getByLabelText('Password');
|
||||
|
||||
fireEvent.change(username, { target: { value: 'admin' } });
|
||||
fireEvent.change(password, { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Login' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthDataMock).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', username: 'local-user', role: 'admin' },
|
||||
'access-token'
|
||||
);
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBe('access-token');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('refresh-token');
|
||||
});
|
||||
|
||||
it('validates registration passwords before sending request', async () => {
|
||||
const providers = {
|
||||
...baseProviders,
|
||||
providers: ['local'],
|
||||
registrationEnabled: true,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
const registerToggle = await screen.findByRole('button', { name: /Don't have an account\? Register/i });
|
||||
fireEvent.click(registerToggle);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
|
||||
const passwordInputs = screen.getAllByLabelText('Password');
|
||||
fireEvent.change(passwordInputs[0], { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password2' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
expect(await screen.findByText('Passwords do not match')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an OIDC login button and redirects to the provider', async () => {
|
||||
const providers = {
|
||||
...baseProviders,
|
||||
providers: ['oidc'],
|
||||
oidcProviderName: 'Auth0',
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Login with Auth0' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("You'll be redirected to Auth0 to authenticate")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('logs in via admin credentials when Plex mode exposes admin login', async () => {
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
if (url === '/api/auth/admin/login') {
|
||||
return makeJsonResponse({
|
||||
accessToken: 'admin-access',
|
||||
refreshToken: 'admin-refresh',
|
||||
user: { id: 'admin-1', username: 'admin', role: 'admin' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
const toggleButton = await screen.findByRole('button', { name: 'Admin Login' });
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthDataMock).toHaveBeenCalledWith(
|
||||
{ id: 'admin-1', username: 'admin', role: 'admin' },
|
||||
'admin-access'
|
||||
);
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBe('admin-access');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('admin-refresh');
|
||||
});
|
||||
|
||||
it('renders book cover images when the covers API returns data', async () => {
|
||||
window.innerWidth = 500;
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') {
|
||||
return makeJsonResponse({
|
||||
success: true,
|
||||
covers: [
|
||||
{
|
||||
asin: 'asin-1',
|
||||
title: 'Book One',
|
||||
author: 'Author',
|
||||
coverUrl: '/cover.jpg',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(await screen.findByAltText('Book One')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pending approval alert when admin login returns pending status', async () => {
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
if (url === '/api/auth/admin/login') {
|
||||
return makeJsonResponse({ pendingApproval: true });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Admin Login' }));
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' }));
|
||||
|
||||
expect(await screen.findByText('Account Pending Approval')).toBeInTheDocument();
|
||||
expect(setAuthDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows registration pending alert when registration needs approval', async () => {
|
||||
const providers = {
|
||||
...baseProviders,
|
||||
providers: ['local'],
|
||||
registrationEnabled: true,
|
||||
};
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
if (url === '/api/auth/register') {
|
||||
return makeJsonResponse({ pendingApproval: true });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /Don't have an account\? Register/i })
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
expect(await screen.findByText('Registration Pending')).toBeInTheDocument();
|
||||
expect(setAuthDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-logs in after successful registration', async () => {
|
||||
const providers = {
|
||||
...baseProviders,
|
||||
providers: ['local'],
|
||||
registrationEnabled: true,
|
||||
};
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
if (url === '/api/auth/register') {
|
||||
return makeJsonResponse({
|
||||
success: true,
|
||||
accessToken: 'reg-access',
|
||||
refreshToken: 'reg-refresh',
|
||||
user: { id: 'user-3', username: 'new-user', role: 'user' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /Don't have an account\? Register/i })
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } });
|
||||
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthDataMock).toHaveBeenCalledWith(
|
||||
{ id: 'user-3', username: 'new-user', role: 'user' },
|
||||
'reg-access'
|
||||
);
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBe('reg-access');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('reg-refresh');
|
||||
});
|
||||
|
||||
it('falls back to Plex mode when providers fetch fails', async () => {
|
||||
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') {
|
||||
throw new Error('providers down');
|
||||
}
|
||||
if (url === '/api/audiobooks/covers') {
|
||||
throw new Error('covers down');
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Login with Plex' })).toBeInTheDocument();
|
||||
expect(errorMock).toHaveBeenCalledWith('Failed to fetch auth providers:', expect.any(Error));
|
||||
expect(errorMock).toHaveBeenCalledWith('Failed to fetch book covers:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('processes mobile auth data from URL hash', async () => {
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
setMockSearchParams('auth=success&redirect=/requests');
|
||||
|
||||
const authData = {
|
||||
accessToken: 'mobile-access',
|
||||
refreshToken: 'mobile-refresh',
|
||||
user: { id: 'user-9', username: 'mobile-user', role: 'user' },
|
||||
};
|
||||
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthDataMock).toHaveBeenCalledWith(authData.user, authData.accessToken);
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/requests');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBe('mobile-access');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('mobile-refresh');
|
||||
expect(window.location.hash).toBe('');
|
||||
});
|
||||
|
||||
it('shows error message from query string', async () => {
|
||||
setMockSearchParams('error=Access%20Denied');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
||||
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: LoginPage } = await import('@/app/login/page');
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(await screen.findByText('Access Denied')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: Profile Page Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const useRequestsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useRequests: useRequestsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/Header', () => ({
|
||||
Header: () => <div data-testid="header" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/RequestCard', () => ({
|
||||
RequestCard: ({ request, showActions }: { request: any; showActions?: boolean }) => (
|
||||
<div
|
||||
data-testid="request-card"
|
||||
data-request-id={request.id}
|
||||
data-show-actions={String(!!showActions)}
|
||||
>
|
||||
{request.id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const getStatValue = (label: string) => {
|
||||
const labelNode = screen.getByText(label);
|
||||
const container = labelNode.parentElement;
|
||||
const valueNode = container?.querySelector('p:nth-of-type(2)');
|
||||
return valueNode?.textContent;
|
||||
};
|
||||
|
||||
describe('ProfilePage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
useRequestsMock.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('prompts for authentication when no user is available', async () => {
|
||||
setMockAuthState({ user: null });
|
||||
useRequestsMock.mockReturnValue({ requests: [], isLoading: false });
|
||||
|
||||
const { default: ProfilePage } = await import('@/app/profile/page');
|
||||
render(<ProfilePage />);
|
||||
|
||||
expect(screen.getByText('Authentication Required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please log in to view your profile')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates stats and orders recent requests', async () => {
|
||||
setMockAuthState({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const requests = [
|
||||
{ id: 'req-1', status: 'pending', createdAt: '2025-01-01T10:00:00Z', audiobook: {} },
|
||||
{ id: 'req-2', status: 'awaiting_search', createdAt: '2025-01-02T10:00:00Z', audiobook: {} },
|
||||
{ id: 'req-3', status: 'available', createdAt: '2025-01-03T10:00:00Z', audiobook: {} },
|
||||
{ id: 'req-4', status: 'failed', createdAt: '2025-01-04T10:00:00Z', audiobook: {} },
|
||||
{ id: 'req-5', status: 'cancelled', createdAt: '2025-01-05T10:00:00Z', audiobook: {} },
|
||||
{ id: 'req-6', status: 'searching', createdAt: '2025-01-06T10:00:00Z', audiobook: {} },
|
||||
];
|
||||
|
||||
useRequestsMock.mockReturnValue({ requests, isLoading: false });
|
||||
|
||||
const { default: ProfilePage } = await import('@/app/profile/page');
|
||||
render(<ProfilePage />);
|
||||
|
||||
expect(getStatValue('Total')).toBe('6');
|
||||
expect(getStatValue('Active')).toBe('2');
|
||||
expect(getStatValue('Waiting')).toBe('1');
|
||||
expect(getStatValue('Completed')).toBe('1');
|
||||
expect(getStatValue('Failed')).toBe('1');
|
||||
expect(getStatValue('Cancelled')).toBe('1');
|
||||
|
||||
const cards = screen.getAllByTestId('request-card');
|
||||
expect(cards).toHaveLength(5);
|
||||
expect(cards[0]).toHaveAttribute('data-request-id', 'req-6');
|
||||
});
|
||||
|
||||
it('shows active downloads when downloading requests exist', async () => {
|
||||
setMockAuthState({
|
||||
user: {
|
||||
id: 'user-2',
|
||||
plexId: 'plex-2',
|
||||
username: 'download-user',
|
||||
role: 'user',
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const requests = [
|
||||
{ id: 'req-downloading', status: 'downloading', createdAt: '2025-02-01T10:00:00Z', audiobook: {} },
|
||||
{ id: 'req-processing', status: 'processing', createdAt: '2025-02-02T10:00:00Z', audiobook: {} },
|
||||
{ id: 'req-pending', status: 'pending', createdAt: '2025-02-03T10:00:00Z', audiobook: {} },
|
||||
];
|
||||
|
||||
useRequestsMock.mockReturnValue({ requests, isLoading: false });
|
||||
|
||||
const { default: ProfilePage } = await import('@/app/profile/page');
|
||||
render(<ProfilePage />);
|
||||
|
||||
expect(screen.getByText('Active Downloads')).toBeInTheDocument();
|
||||
const cards = screen.getAllByTestId('request-card');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Component: Requests Page Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const useRequestsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useRequests: useRequestsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/Header', () => ({
|
||||
Header: () => <div data-testid="header" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/RequestCard', () => ({
|
||||
RequestCard: ({ request, showActions }: { request: any; showActions?: boolean }) => (
|
||||
<div
|
||||
data-testid="request-card"
|
||||
data-status={request.status}
|
||||
data-show-actions={String(!!showActions)}
|
||||
>
|
||||
{request.id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('RequestsPage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
useRequestsMock.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('prompts for authentication when no user is available', async () => {
|
||||
setMockAuthState({ user: null });
|
||||
useRequestsMock.mockReturnValue({ requests: [], isLoading: false });
|
||||
|
||||
const { default: RequestsPage } = await import('@/app/requests/page');
|
||||
render(<RequestsPage />);
|
||||
|
||||
expect(screen.getByText('Authentication Required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please log in to view your audiobook requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters requests by status and updates tab counts', async () => {
|
||||
setMockAuthState({
|
||||
user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const requests = [
|
||||
{ id: 'req-active', status: 'pending', audiobook: { title: 'Active', author: 'Author' } },
|
||||
{ id: 'req-wait', status: 'awaiting_search', audiobook: { title: 'Wait', author: 'Author' } },
|
||||
{ id: 'req-complete', status: 'downloaded', audiobook: { title: 'Done', author: 'Author' } },
|
||||
{ id: 'req-failed', status: 'failed', audiobook: { title: 'Fail', author: 'Author' } },
|
||||
];
|
||||
|
||||
useRequestsMock.mockReturnValue({ requests, isLoading: false });
|
||||
|
||||
const { default: RequestsPage } = await import('@/app/requests/page');
|
||||
render(<RequestsPage />);
|
||||
|
||||
const activeTab = screen.getByRole('button', { name: /Active/i });
|
||||
const waitingTab = screen.getByRole('button', { name: /Waiting/i });
|
||||
|
||||
expect(activeTab).toHaveTextContent('(1)');
|
||||
expect(waitingTab).toHaveTextContent('(1)');
|
||||
|
||||
fireEvent.click(activeTab);
|
||||
|
||||
const activeCards = screen.getAllByTestId('request-card');
|
||||
expect(activeCards).toHaveLength(1);
|
||||
expect(activeCards[0]).toHaveAttribute('data-status', 'pending');
|
||||
|
||||
fireEvent.click(waitingTab);
|
||||
|
||||
const waitingCards = screen.getAllByTestId('request-card');
|
||||
expect(waitingCards).toHaveLength(1);
|
||||
expect(waitingCards[0]).toHaveAttribute('data-status', 'awaiting_search');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: Search Page Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const useSearchMock = vi.hoisted(() => vi.fn());
|
||||
const usePreferencesMock = vi.hoisted(() => ({
|
||||
cardSize: 5,
|
||||
setCardSize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
||||
useSearch: useSearchMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/contexts/PreferencesContext', () => ({
|
||||
usePreferences: () => usePreferencesMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/auth/ProtectedRoute', () => ({
|
||||
ProtectedRoute: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/layout/Header', () => ({
|
||||
Header: () => <div data-testid="header" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
|
||||
AudiobookGrid: ({
|
||||
audiobooks,
|
||||
emptyMessage,
|
||||
cardSize,
|
||||
}: {
|
||||
audiobooks: any[];
|
||||
emptyMessage: string;
|
||||
cardSize?: number;
|
||||
}) => (
|
||||
<div data-testid="grid" data-count={audiobooks.length} data-size={cardSize}>
|
||||
<span>{emptyMessage}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/CardSizeControls', () => ({
|
||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||
}));
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
useSearchMock.mockReset();
|
||||
usePreferencesMock.cardSize = 5;
|
||||
usePreferencesMock.setCardSize.mockReset();
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows the empty state before a search query is entered', async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
results: [],
|
||||
totalResults: 0,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { default: SearchPage } = await import('@/app/search/page');
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument();
|
||||
expect(useSearchMock).toHaveBeenCalledWith('', 1);
|
||||
});
|
||||
|
||||
it('debounces search input and loads more results', async () => {
|
||||
useSearchMock.mockImplementation((query: string, page: number) => {
|
||||
if (!query) {
|
||||
return { results: [], totalResults: 0, hasMore: false, isLoading: false };
|
||||
}
|
||||
if (page === 1) {
|
||||
return {
|
||||
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
|
||||
totalResults: 2,
|
||||
hasMore: true,
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }],
|
||||
totalResults: 2,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
|
||||
const { default: SearchPage } = await import('@/app/search/page');
|
||||
render(<SearchPage />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search by title, author, or narrator...');
|
||||
fireEvent.change(input, { target: { value: 'Dune' } });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Search Results')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Load More Results' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('grid')).toHaveAttribute('data-count', '1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load More Results' }));
|
||||
|
||||
expect(useSearchMock).toHaveBeenCalledWith('Dune', 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Component: Select Profile Page Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
|
||||
import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation';
|
||||
|
||||
const makeJsonResponse = (body: any, ok = true, status = 200) => ({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('SelectProfilePage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows an error when session info is missing', async () => {
|
||||
setMockSearchParams('');
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page');
|
||||
render(<SelectProfilePage />);
|
||||
|
||||
expect(await screen.findByText('Invalid session. Please try logging in again.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Login' }));
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('selects an unprotected profile and stores auth data', async () => {
|
||||
sessionStorage.setItem('plex_main_token', 'main-token');
|
||||
setMockSearchParams('pinId=123');
|
||||
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
id: 'profile-1',
|
||||
uuid: 'uuid-1',
|
||||
title: 'User',
|
||||
friendlyName: 'Primary',
|
||||
username: 'primary',
|
||||
email: 'primary@example.com',
|
||||
thumb: 'http://thumb',
|
||||
hasPassword: false,
|
||||
restricted: false,
|
||||
admin: true,
|
||||
guest: false,
|
||||
protected: false,
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/plex/home-users') {
|
||||
return makeJsonResponse({ users: profiles });
|
||||
}
|
||||
if (url === '/api/auth/plex/switch-profile') {
|
||||
return makeJsonResponse({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
user: { id: 'user-1', plexId: 'plex-1', username: 'primary', role: 'user' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page');
|
||||
render(<SelectProfilePage />);
|
||||
|
||||
const profileButton = await screen.findByRole('button', { name: /Primary/ });
|
||||
fireEvent.click(profileButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthDataMock).toHaveBeenCalledWith(
|
||||
{ id: 'user-1', plexId: 'plex-1', username: 'primary', role: 'user' },
|
||||
'access-token'
|
||||
);
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
|
||||
const body = JSON.parse((fetchMock.mock.calls[1][1] as RequestInit).body as string);
|
||||
expect(body.userId).toBe('profile-1');
|
||||
expect(body.pinId).toBe('123');
|
||||
expect(localStorage.getItem('accessToken')).toBe('access-token');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('refresh-token');
|
||||
});
|
||||
|
||||
it('prompts for a PIN and handles invalid submissions', async () => {
|
||||
sessionStorage.setItem('plex_main_token', 'main-token');
|
||||
setMockSearchParams('pinId=555');
|
||||
|
||||
const setAuthDataMock = vi.fn();
|
||||
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
id: 'profile-2',
|
||||
uuid: 'uuid-2',
|
||||
title: 'Protected',
|
||||
friendlyName: 'Protected',
|
||||
username: 'protected',
|
||||
email: 'protected@example.com',
|
||||
thumb: '',
|
||||
hasPassword: true,
|
||||
restricted: false,
|
||||
admin: false,
|
||||
guest: false,
|
||||
protected: true,
|
||||
},
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/auth/plex/home-users') {
|
||||
return makeJsonResponse({ users: profiles });
|
||||
}
|
||||
if (url === '/api/auth/plex/switch-profile') {
|
||||
return makeJsonResponse({ message: 'Invalid PIN' }, false, 401);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page');
|
||||
render(<SelectProfilePage />);
|
||||
|
||||
const profileButton = await screen.findByRole('button', { name: /Protected/ });
|
||||
fireEvent.click(profileButton);
|
||||
|
||||
const pinInput = await screen.findByPlaceholderText('Enter PIN');
|
||||
fireEvent.change(pinInput, { target: { value: '1234' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(await screen.findByText('Invalid PIN. Please try again.')).toBeInTheDocument();
|
||||
expect((pinInput as HTMLInputElement).value).toBe('');
|
||||
expect(setAuthDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Component: Setup Wizard Page Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
||||
|
||||
const mockSetupModules = () => {
|
||||
vi.doMock(path.resolve('src/app/setup/components/WizardLayout.tsx'), () => ({
|
||||
WizardLayout: ({
|
||||
children,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}) => (
|
||||
<div data-testid="wizard" data-step={currentStep} data-total={totalSteps}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/WelcomeStep.tsx'), () => ({
|
||||
WelcomeStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/BackendSelectionStep.tsx'), () => ({
|
||||
BackendSelectionStep: ({
|
||||
onNext,
|
||||
onChange,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onChange: (value: 'plex' | 'audiobookshelf') => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onChange('plex')}>
|
||||
Choose Plex
|
||||
</button>
|
||||
<button type="button" onClick={() => onChange('audiobookshelf')}>
|
||||
Choose ABS
|
||||
</button>
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/AdminAccountStep.tsx'), () => ({
|
||||
AdminAccountStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/PlexStep.tsx'), () => ({
|
||||
PlexStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/AudiobookshelfStep.tsx'), () => ({
|
||||
AudiobookshelfStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/AuthMethodStep.tsx'), () => ({
|
||||
AuthMethodStep: ({
|
||||
onNext,
|
||||
onChange,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onChange: (value: 'oidc' | 'manual' | 'both') => void;
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onChange('oidc')}>
|
||||
Choose OIDC
|
||||
</button>
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/OIDCConfigStep.tsx'), () => ({
|
||||
OIDCConfigStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/RegistrationSettingsStep.tsx'), () => ({
|
||||
RegistrationSettingsStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/ProwlarrStep.tsx'), () => ({
|
||||
ProwlarrStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/DownloadClientStep.tsx'), () => ({
|
||||
DownloadClientStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/PathsStep.tsx'), () => ({
|
||||
PathsStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/BookDateStep.tsx'), () => ({
|
||||
BookDateStep: ({ onNext }: { onNext: () => void }) => (
|
||||
<button type="button" onClick={onNext}>
|
||||
Next
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/ReviewStep.tsx'), () => ({
|
||||
ReviewStep: ({ onComplete }: { onComplete: () => void }) => (
|
||||
<button type="button" onClick={onComplete}>
|
||||
Complete
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.doMock(path.resolve('src/app/setup/steps/FinalizeStep.tsx'), () => ({
|
||||
FinalizeStep: ({ hasAdminTokens }: { hasAdminTokens: boolean }) => (
|
||||
<div data-testid="finalize">{hasAdminTokens ? 'admin' : 'oidc'}</div>
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const makeJsonResponse = (body: any, ok: boolean = true) => ({
|
||||
ok,
|
||||
status: ok ? 200 : 500,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('SetupWizard', () => {
|
||||
beforeEach(() => {
|
||||
resetMockRouter();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('completes setup in Plex mode and stores admin tokens', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/setup/complete') {
|
||||
return makeJsonResponse({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
user: { id: 'admin-1', username: 'admin' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
vi.resetModules();
|
||||
mockSetupModules();
|
||||
const { default: SetupWizard } = await import('@/app/setup/page');
|
||||
render(<SetupWizard />);
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
||||
}
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('accessToken')).toBe('access-token');
|
||||
expect(screen.getByTestId('finalize')).toHaveTextContent('admin');
|
||||
});
|
||||
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(requestBody.backendMode).toBe('plex');
|
||||
expect(requestBody.admin).toBeDefined();
|
||||
expect(requestBody.plex).toBeDefined();
|
||||
});
|
||||
|
||||
it('completes setup in OIDC-only mode and clears tokens', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.url;
|
||||
if (url === '/api/setup/complete') {
|
||||
return makeJsonResponse({ success: true });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'stale-token');
|
||||
|
||||
vi.resetModules();
|
||||
mockSetupModules();
|
||||
const { default: SetupWizard } = await import('@/app/setup/page');
|
||||
render(<SetupWizard />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Choose OIDC' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
||||
}
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
expect(screen.getByTestId('finalize')).toHaveTextContent('oidc');
|
||||
});
|
||||
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(requestBody.backendMode).toBe('audiobookshelf');
|
||||
expect(requestBody.authMethod).toBe('oidc');
|
||||
expect(requestBody.audiobookshelf).toBeDefined();
|
||||
expect(requestBody.oidc).toBeDefined();
|
||||
expect(requestBody.admin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Component: Setup Wizard Layout Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('WizardLayout', () => {
|
||||
it('renders Plex steps and footer progress', async () => {
|
||||
const { WizardLayout } = await import('@/app/setup/components/WizardLayout');
|
||||
|
||||
render(
|
||||
<WizardLayout currentStep={3} totalSteps={10} backendMode="plex">
|
||||
<div>Content</div>
|
||||
</WizardLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByText('ReadMeABook Setup')).toBeInTheDocument();
|
||||
expect(screen.getByText('Plex')).toBeInTheDocument();
|
||||
expect(screen.getByText('Finalize')).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 3 of 10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Audiobookshelf steps based on auth method', async () => {
|
||||
const { WizardLayout } = await import('@/app/setup/components/WizardLayout');
|
||||
|
||||
render(
|
||||
<WizardLayout currentStep={2} totalSteps={8} backendMode="audiobookshelf" authMethod="oidc">
|
||||
<div>Content</div>
|
||||
</WizardLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByText('ABS')).toBeInTheDocument();
|
||||
expect(screen.getByText('Auth')).toBeInTheDocument();
|
||||
expect(screen.getByText('OIDC')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Registration')).toBeNull();
|
||||
expect(screen.queryByText('Admin')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Component: Setup Initializing Page Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { resetMockRouter, routerMock } from '../../helpers/mock-next-navigation';
|
||||
|
||||
describe('InitializingPage', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
window.location.hash = '';
|
||||
resetMockRouter();
|
||||
});
|
||||
|
||||
it('redirects to login when auth data is missing', async () => {
|
||||
window.location.hash = '';
|
||||
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
|
||||
|
||||
render(<InitializingPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerMock.push).toHaveBeenCalledWith(
|
||||
'/login?error=Authentication%20data%20missing'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('processes auth data and completes job monitoring', async () => {
|
||||
vi.useFakeTimers();
|
||||
const authData = {
|
||||
accessToken: 'token-123',
|
||||
refreshToken: 'refresh-123',
|
||||
user: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url === '/api/admin/jobs') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
jobs: [
|
||||
{ id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' },
|
||||
{ id: 'job-2', type: 'plex_library_scan', lastRunJobId: 'run-2' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') {
|
||||
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
|
||||
|
||||
render(<InitializingPage />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBe('token-123');
|
||||
expect(window.location.hash).toBe('');
|
||||
|
||||
const completedMessages = screen.getAllByText('Completed successfully');
|
||||
expect(completedMessages.length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('marks jobs as error when no recent job is found', async () => {
|
||||
vi.useFakeTimers();
|
||||
const authData = {
|
||||
accessToken: 'token-123',
|
||||
refreshToken: 'refresh-123',
|
||||
user: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url === '/api/admin/jobs') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
jobs: [
|
||||
{ id: 'job-1', type: 'audible_refresh' },
|
||||
{ id: 'job-2', type: 'plex_library_scan' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
|
||||
|
||||
render(<InitializingPage />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/Job did not start/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('redirects when auth data fails to parse', async () => {
|
||||
window.location.hash = '#authData=';
|
||||
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
|
||||
|
||||
render(<InitializingPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerMock.push).toHaveBeenCalledWith(
|
||||
'/login?error=Failed%20to%20process%20authentication'
|
||||
);
|
||||
});
|
||||
expect(errorMock).toHaveBeenCalledWith(
|
||||
'[Initializing] Failed to process auth data:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('marks jobs as error when scheduled jobs fetch fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
const authData = {
|
||||
accessToken: 'token-123',
|
||||
refreshToken: 'refresh-123',
|
||||
user: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url === '/api/admin/jobs') {
|
||||
return { ok: false, json: async () => ({}) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
|
||||
|
||||
render(<InitializingPage />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/Failed to fetch job configuration/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('marks jobs as failed when job status returns failed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const authData = {
|
||||
accessToken: 'token-123',
|
||||
refreshToken: 'refresh-123',
|
||||
user: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url === '/api/admin/jobs') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
jobs: [
|
||||
{ id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' },
|
||||
{ id: 'job-2', type: 'plex_library_scan', lastRunJobId: 'run-2' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') {
|
||||
return { ok: true, json: async () => ({ job: { status: 'failed' } }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { default: InitializingPage } = await import('@/app/setup/initializing/page');
|
||||
|
||||
render(<InitializingPage />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/Job failed to complete/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Component: Admin Account Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AdminAccountStep } from '@/app/setup/steps/AdminAccountStep';
|
||||
|
||||
const AdminAccountHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialUsername = '',
|
||||
initialPassword = '',
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialUsername?: string;
|
||||
initialPassword?: string;
|
||||
}) => {
|
||||
const [adminUsername, setAdminUsername] = useState(initialUsername);
|
||||
const [adminPassword, setAdminPassword] = useState(initialPassword);
|
||||
|
||||
return (
|
||||
<AdminAccountStep
|
||||
adminUsername={adminUsername}
|
||||
adminPassword={adminPassword}
|
||||
onUpdate={(field, value) => {
|
||||
if (field === 'adminUsername') {
|
||||
setAdminUsername(value);
|
||||
}
|
||||
if (field === 'adminPassword') {
|
||||
setAdminPassword(value);
|
||||
}
|
||||
}}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AdminAccountStep', () => {
|
||||
it('shows validation errors and blocks next when invalid', async () => {
|
||||
const onNext = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
render(
|
||||
<AdminAccountStep
|
||||
adminUsername="ad"
|
||||
adminPassword="short"
|
||||
onUpdate={vi.fn()}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('Passwords do not match')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows navigation when credentials are valid', async () => {
|
||||
const onNext = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
render(
|
||||
<AdminAccountHarness
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
initialUsername="admin"
|
||||
initialPassword="supersecret"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Confirm Password'), {
|
||||
target: { value: 'supersecret' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Component: Setup Audiobookshelf Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AudiobookshelfStep } from '@/app/setup/steps/AudiobookshelfStep';
|
||||
|
||||
const AudiobookshelfHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialState,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof AudiobookshelfStep>>;
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
absUrl: 'http://abs.local',
|
||||
absApiToken: 'token',
|
||||
absLibraryId: '',
|
||||
absTriggerScanAfterImport: false,
|
||||
...initialState,
|
||||
});
|
||||
|
||||
return (
|
||||
<AudiobookshelfStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AudiobookshelfStep', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('requires library selection after successful test', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
libraries: [{ id: 'lib-1', name: 'Main', itemCount: 10 }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(<AudiobookshelfHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-abs', expect.any(Object));
|
||||
});
|
||||
|
||||
await screen.findByText('Connection successful!');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(screen.getByText('Please select an audiobook library')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
await screen.findByText('Connection successful!');
|
||||
|
||||
const librarySelect = await screen.findByRole('combobox');
|
||||
fireEvent.change(librarySelect, { target: { value: 'lib-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(librarySelect).toHaveValue('lib-1');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: Auth Method Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('AuthMethodStep', () => {
|
||||
it('highlights the selected auth method', async () => {
|
||||
const { AuthMethodStep } = await import('@/app/setup/steps/AuthMethodStep');
|
||||
|
||||
const { rerender } = render(
|
||||
<AuthMethodStep
|
||||
value="oidc"
|
||||
onChange={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const oidcLabel = screen.getByRole('radio', { name: /OIDC Provider/i }).closest('label');
|
||||
const manualLabel = screen.getByRole('radio', { name: /Manual Registration/i }).closest('label');
|
||||
const bothLabel = screen.getByRole('radio', { name: /Both/i }).closest('label');
|
||||
|
||||
expect(oidcLabel).toHaveClass('border-blue-500');
|
||||
expect(manualLabel).toHaveClass('border-gray-200');
|
||||
expect(bothLabel).toHaveClass('border-gray-200');
|
||||
|
||||
rerender(
|
||||
<AuthMethodStep
|
||||
value="manual"
|
||||
onChange={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: /Manual Registration/i }).closest('label')).toHaveClass('border-blue-500');
|
||||
|
||||
rerender(
|
||||
<AuthMethodStep
|
||||
value="both"
|
||||
onChange={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: /Both/i }).closest('label')).toHaveClass('border-blue-500');
|
||||
});
|
||||
|
||||
it('updates auth method and navigates', async () => {
|
||||
const onChange = vi.fn();
|
||||
const onNext = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const { AuthMethodStep } = await import('@/app/setup/steps/AuthMethodStep');
|
||||
|
||||
const { rerender } = render(
|
||||
<AuthMethodStep
|
||||
value="oidc"
|
||||
onChange={onChange}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Manual Registration/i }));
|
||||
expect(onChange).toHaveBeenCalledWith('manual');
|
||||
|
||||
rerender(
|
||||
<AuthMethodStep
|
||||
value="manual"
|
||||
onChange={onChange}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /OIDC Provider/i }));
|
||||
expect(onChange).toHaveBeenCalledWith('oidc');
|
||||
|
||||
rerender(
|
||||
<AuthMethodStep
|
||||
value="oidc"
|
||||
onChange={onChange}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Both/i }));
|
||||
expect(onChange).toHaveBeenCalledWith('both');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Component: Backend Selection Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('BackendSelectionStep', () => {
|
||||
it('updates the audible region helper based on backend', async () => {
|
||||
const { BackendSelectionStep } = await import('@/app/setup/steps/BackendSelectionStep');
|
||||
|
||||
const { rerender } = render(
|
||||
<BackendSelectionStep
|
||||
value="plex"
|
||||
onChange={vi.fn()}
|
||||
audibleRegion="us"
|
||||
onAudibleRegionChange={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/configuration in Plex/i)).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<BackendSelectionStep
|
||||
value="audiobookshelf"
|
||||
onChange={vi.fn()}
|
||||
audibleRegion="us"
|
||||
onAudibleRegionChange={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/configuration in Audiobookshelf/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates backend selection and audible region', async () => {
|
||||
const onChange = vi.fn();
|
||||
const onAudibleRegionChange = vi.fn();
|
||||
const onNext = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const { BackendSelectionStep } = await import('@/app/setup/steps/BackendSelectionStep');
|
||||
|
||||
render(
|
||||
<BackendSelectionStep
|
||||
value="plex"
|
||||
onChange={onChange}
|
||||
audibleRegion="us"
|
||||
onAudibleRegionChange={onAudibleRegionChange}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Audiobookshelf/i }));
|
||||
expect(onChange).toHaveBeenCalledWith('audiobookshelf');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Audible Region'), { target: { value: 'uk' } });
|
||||
expect(onAudibleRegionChange).toHaveBeenCalledWith('uk');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Component: Setup BookDate Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BookDateStep } from '@/app/setup/steps/BookDateStep';
|
||||
|
||||
const BookDateHarness = ({
|
||||
onNext,
|
||||
onSkip,
|
||||
onBack,
|
||||
initialState,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof BookDateStep>>;
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
bookdateProvider: 'openai',
|
||||
bookdateApiKey: '',
|
||||
bookdateModel: '',
|
||||
bookdateConfigured: false,
|
||||
...initialState,
|
||||
});
|
||||
|
||||
return (
|
||||
<BookDateStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
onNext={onNext}
|
||||
onSkip={onSkip}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('BookDateStep', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('disables connection test when API key is missing', async () => {
|
||||
render(
|
||||
<BookDateHarness onNext={vi.fn()} onSkip={vi.fn()} onBack={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Test Connection/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('fetches models and proceeds after successful test', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ id: 'model-1', name: 'Model One' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(
|
||||
<BookDateHarness
|
||||
onNext={onNext}
|
||||
onSkip={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ bookdateApiKey: 'key' }}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/test-connection', expect.any(Object));
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Select Model')).toBeInTheDocument();
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
await waitFor(() => {
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(nextButton);
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error when the connection test fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Invalid API key' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<BookDateHarness
|
||||
onNext={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ bookdateApiKey: 'bad-key' }}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid API key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-selects the first model and shows the note', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ id: 'model-1', name: 'Model One' },
|
||||
{ id: 'model-2', name: 'Model Two' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<BookDateHarness
|
||||
onNext={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ bookdateApiKey: 'key' }}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Model')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
const modelSelect = selects[1];
|
||||
expect(modelSelect).toHaveValue('model-1');
|
||||
expect(screen.getByText(/Library scope and custom prompt preferences/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears tested state and models when switching providers', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [{ id: 'model-1', name: 'Model One' }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<BookDateHarness
|
||||
onNext={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ bookdateApiKey: 'key' }}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Test Connection/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Model')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const providerSelect = screen.getAllByRole('combobox')[0];
|
||||
fireEvent.change(providerSelect, { target: { value: 'claude' } });
|
||||
|
||||
expect(screen.queryByText('Select Model')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Component: Setup Download Client Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep';
|
||||
|
||||
const DownloadClientHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialState,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof DownloadClientStep>>;
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
downloadClient: 'qbittorrent' as const,
|
||||
downloadClientUrl: 'https://qbittorrent.local',
|
||||
downloadClientUsername: 'admin',
|
||||
downloadClientPassword: 'secret',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
...initialState,
|
||||
});
|
||||
|
||||
return (
|
||||
<DownloadClientStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('DownloadClientStep', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('tests connection and enables navigation after success', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, version: '1.2.3' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Connected successfully!/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows remote path fields and toggles SSL verify', async () => {
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
const sslToggle = screen.getByLabelText('Disable SSL Certificate Verification');
|
||||
fireEvent.click(sslToggle);
|
||||
expect(sslToggle).toBeChecked();
|
||||
|
||||
const remoteToggle = screen.getByLabelText('Enable Remote Path Mapping');
|
||||
fireEvent.click(remoteToggle);
|
||||
|
||||
expect(screen.getByPlaceholderText('/remote/mnt/d/done')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('/downloads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches to SABnzbd and shows API key field', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ downloadClient: 'qbittorrent' }}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /SABnzbd/ }));
|
||||
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Username')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks next when connection has not been tested', async () => {
|
||||
const onNext = vi.fn();
|
||||
render(<DownloadClientHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText('Please test the connection before proceeding')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error when the connection test fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Bad credentials' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object));
|
||||
});
|
||||
|
||||
expect(screen.getByText('Bad credentials')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables test connection when SABnzbd fields are incomplete', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{
|
||||
downloadClient: 'sabnzbd',
|
||||
downloadClientUrl: '',
|
||||
downloadClientPassword: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const testButton = screen.getByRole('button', { name: 'Test Connection' });
|
||||
expect(testButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides SSL toggle when using http URLs', async () => {
|
||||
render(
|
||||
<DownloadClientHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ downloadClientUrl: 'http://qbittorrent.local' }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('Disable SSL Certificate Verification')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Component: Finalize Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('FinalizeStep', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows OIDC-only instructions and completes setup', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
|
||||
|
||||
render(
|
||||
<FinalizeStep hasAdminTokens={false} onComplete={onComplete} onBack={onBack} />
|
||||
);
|
||||
|
||||
expect(screen.getByText('Setup Complete!')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Finish Setup' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks jobs as error when no access token is available', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
|
||||
|
||||
render(
|
||||
<FinalizeStep hasAdminTokens={true} onComplete={onComplete} onBack={onBack} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/Authentication required/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('runs initial jobs and enables completion on success', async () => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url === '/api/admin/jobs') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
jobs: [
|
||||
{ id: 'job-1', type: 'audible_refresh' },
|
||||
{ id: 'job-2', type: 'plex_library_scan' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === '/api/admin/jobs/job-1/trigger') {
|
||||
return { ok: true, json: async () => ({ jobId: 'run-1' }) };
|
||||
}
|
||||
if (url === '/api/admin/jobs/job-2/trigger') {
|
||||
return { ok: true, json: async () => ({ jobId: 'run-2' }) };
|
||||
}
|
||||
if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') {
|
||||
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const onComplete = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
|
||||
|
||||
render(
|
||||
<FinalizeStep hasAdminTokens={true} onComplete={onComplete} onBack={onBack} />
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('Completed successfully').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Finish Setup' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('marks missing job configuration as an error', async () => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
if (url === '/api/admin/jobs') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
jobs: [
|
||||
{ id: 'job-1', type: 'audible_refresh' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url === '/api/admin/jobs/job-1/trigger') {
|
||||
return { ok: true, json: async () => ({ jobId: 'run-1' }) };
|
||||
}
|
||||
if (url === '/api/admin/job-status/run-1') {
|
||||
return { ok: true, json: async () => ({ job: { status: 'completed' } }) };
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep');
|
||||
|
||||
render(
|
||||
<FinalizeStep hasAdminTokens={true} onComplete={vi.fn()} onBack={vi.fn()} />
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/Job configuration not found/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Finish Setup' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Component: Setup OIDC Config Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { OIDCConfigStep } from '@/app/setup/steps/OIDCConfigStep';
|
||||
|
||||
const OIDCHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialState,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof OIDCConfigStep>>;
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
oidcProviderName: 'Auth',
|
||||
oidcIssuerUrl: 'https://auth.example.com',
|
||||
oidcClientId: 'client',
|
||||
oidcClientSecret: 'secret',
|
||||
oidcAccessControlMethod: 'open',
|
||||
oidcAccessGroupClaim: '',
|
||||
oidcAccessGroupValue: '',
|
||||
oidcAllowedEmails: '',
|
||||
oidcAllowedUsernames: '',
|
||||
oidcAdminClaimEnabled: false,
|
||||
oidcAdminClaimName: '',
|
||||
oidcAdminClaimValue: '',
|
||||
...initialState,
|
||||
});
|
||||
|
||||
return (
|
||||
<OIDCConfigStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('OIDCConfigStep', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('requires a successful test before proceeding', async () => {
|
||||
const onNext = vi.fn();
|
||||
render(<OIDCHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(screen.getByText('Please test the OIDC configuration before proceeding')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tests connection and shows access control fields', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(<OIDCHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-oidc', expect.any(Object));
|
||||
});
|
||||
|
||||
expect(screen.getByText('OIDC discovery successful! Provider configuration validated.')).toBeInTheDocument();
|
||||
|
||||
const accessControlSelect = screen.getByRole('combobox');
|
||||
fireEvent.change(accessControlSelect, {
|
||||
target: { value: 'allowed_list' },
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText('user1@example.com, user2@example.com')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('john_doe, jane_smith')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Enable Admin Role Mapping'));
|
||||
|
||||
expect(screen.getByPlaceholderText('groups')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('readmeabook-admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables testing when required fields are missing', () => {
|
||||
render(
|
||||
<OIDCHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ oidcIssuerUrl: '' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Test OIDC Configuration' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error text when connection test fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: false, error: 'Invalid issuer' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<OIDCHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid issuer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error text when connection test throws', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('Network down'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<OIDCHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network down')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates access control helper text and fields per method', () => {
|
||||
render(<OIDCHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||
|
||||
const accessControlSelect = screen.getByRole('combobox');
|
||||
expect(
|
||||
screen.getByText('Anyone who can authenticate with your OIDC provider will have access'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(accessControlSelect, {
|
||||
target: { value: 'group_claim' },
|
||||
});
|
||||
expect(screen.getByText('Only users with a specific group/claim can access')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('readmeabook-users')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(accessControlSelect, {
|
||||
target: { value: 'allowed_list' },
|
||||
});
|
||||
expect(screen.getByText('Only explicitly allowed users can access')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(accessControlSelect, {
|
||||
target: { value: 'admin_approval' },
|
||||
});
|
||||
expect(
|
||||
screen.getByText('New users must be approved by an admin before access is granted'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows proceeding after a successful test', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(<OIDCHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('OIDC discovery successful! Provider configuration validated.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates provider fields and access control inputs', () => {
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<OIDCConfigStep
|
||||
oidcProviderName="Auth"
|
||||
oidcIssuerUrl="https://auth.example.com"
|
||||
oidcClientId="client"
|
||||
oidcClientSecret="secret"
|
||||
oidcAccessControlMethod="group_claim"
|
||||
oidcAccessGroupClaim="groups"
|
||||
oidcAccessGroupValue="readmeabook-users"
|
||||
oidcAllowedEmails=""
|
||||
oidcAllowedUsernames=""
|
||||
oidcAdminClaimEnabled={true}
|
||||
oidcAdminClaimName="groups"
|
||||
oidcAdminClaimValue="readmeabook-admin"
|
||||
onUpdate={onUpdate}
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Authentik'), {
|
||||
target: { value: 'Keycloak' },
|
||||
});
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('https://auth.example.com/application/o/readmeabook/'),
|
||||
{
|
||||
target: { value: 'https://issuer.example' },
|
||||
},
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText('readmeabook'), {
|
||||
target: { value: 'rmab-client' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter client secret'), {
|
||||
target: { value: 'new-secret' },
|
||||
});
|
||||
|
||||
const groupInputs = screen.getAllByPlaceholderText('groups');
|
||||
fireEvent.change(groupInputs[0], { target: { value: 'roles' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('readmeabook-users'), {
|
||||
target: { value: 'rmab-users' },
|
||||
});
|
||||
fireEvent.change(groupInputs[1], { target: { value: 'admin-roles' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('readmeabook-admin'), {
|
||||
target: { value: 'rmab-admin' },
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcProviderName', 'Keycloak');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcIssuerUrl', 'https://issuer.example');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcClientId', 'rmab-client');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcClientSecret', 'new-secret');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAccessGroupClaim', 'roles');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAccessGroupValue', 'rmab-users');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimName', 'admin-roles');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimValue', 'rmab-admin');
|
||||
});
|
||||
|
||||
it('updates allowed list fields and toggles admin mapping', () => {
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<OIDCConfigStep
|
||||
oidcProviderName="Auth"
|
||||
oidcIssuerUrl="https://auth.example.com"
|
||||
oidcClientId="client"
|
||||
oidcClientSecret="secret"
|
||||
oidcAccessControlMethod="allowed_list"
|
||||
oidcAccessGroupClaim=""
|
||||
oidcAccessGroupValue=""
|
||||
oidcAllowedEmails=""
|
||||
oidcAllowedUsernames=""
|
||||
oidcAdminClaimEnabled={false}
|
||||
oidcAdminClaimName=""
|
||||
oidcAdminClaimValue=""
|
||||
onUpdate={onUpdate}
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), {
|
||||
target: { value: 'allowed_list' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('user1@example.com, user2@example.com'), {
|
||||
target: { value: 'reader@example.com' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('john_doe, jane_smith'), {
|
||||
target: { value: 'reader1' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Enable Admin Role Mapping'));
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAccessControlMethod', 'allowed_list');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAllowedEmails', 'reader@example.com');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAllowedUsernames', 'reader1');
|
||||
expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimEnabled', true);
|
||||
});
|
||||
|
||||
it('navigates back when Back is clicked', () => {
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(<OIDCHarness onNext={vi.fn()} onBack={onBack} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Component: Setup Paths Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PathsStep } from '@/app/setup/steps/PathsStep';
|
||||
|
||||
const PathsHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialState,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof PathsStep>>;
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media/audiobooks',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
...initialState,
|
||||
});
|
||||
|
||||
return (
|
||||
<PathsStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('PathsStep', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('validates paths and allows navigation on success', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
message: 'Directories are ready',
|
||||
downloadDirValid: true,
|
||||
mediaDirValid: true,
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(<PathsHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Validate Paths' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-paths', expect.any(Object));
|
||||
});
|
||||
|
||||
expect(screen.getByText('Directories are ready')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires validation before proceeding', async () => {
|
||||
const onNext = vi.fn();
|
||||
render(<PathsHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText('Please validate the paths before proceeding')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles metadata and chapter merge settings', async () => {
|
||||
render(
|
||||
<PathsHarness
|
||||
onNext={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
initialState={{ metadataTaggingEnabled: false, chapterMergingEnabled: false }}
|
||||
/>
|
||||
);
|
||||
|
||||
const metadataToggle = screen.getByLabelText('Auto-tag audio files with metadata');
|
||||
const chapterToggle = screen.getByLabelText('Auto-merge chapters to M4B');
|
||||
|
||||
fireEvent.click(metadataToggle);
|
||||
fireEvent.click(chapterToggle);
|
||||
|
||||
expect(metadataToggle).toBeChecked();
|
||||
expect(chapterToggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Component: Setup Plex Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlexStep } from '@/app/setup/steps/PlexStep';
|
||||
|
||||
const PlexHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialState,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialState?: Partial<React.ComponentProps<typeof PlexStep>>;
|
||||
}) => {
|
||||
const [state, setState] = useState({
|
||||
plexUrl: 'http://plex.local',
|
||||
plexToken: 'token',
|
||||
plexLibraryId: '',
|
||||
plexTriggerScanAfterImport: false,
|
||||
...initialState,
|
||||
});
|
||||
|
||||
return (
|
||||
<PlexStep
|
||||
{...state}
|
||||
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('PlexStep', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('requires library selection after successful test', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
serverName: 'Plex',
|
||||
libraries: [{ id: 'lib-1', title: 'Audiobooks', type: 'artist' }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(<PlexHarness onNext={onNext} onBack={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-plex', expect.any(Object));
|
||||
});
|
||||
|
||||
await screen.findByText(/Connected to Plex/i);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(screen.getByText('Please select an audiobook library')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Connection' }));
|
||||
await screen.findByText(/Connected to Plex/i);
|
||||
|
||||
const librarySelect = await screen.findByRole('combobox');
|
||||
fireEvent.change(librarySelect, { target: { value: 'lib-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(librarySelect).toHaveValue('lib-1');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Component: Setup Prowlarr Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const indexersMock = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Indexer',
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 0,
|
||||
rssEnabled: true,
|
||||
categories: [],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('@/components/admin/indexers/IndexerManagement', () => ({
|
||||
IndexerManagement: ({ onIndexersChange }: { onIndexersChange: (indexers: any[]) => void }) => (
|
||||
<button type="button" onClick={() => onIndexersChange(indexersMock)}>
|
||||
Set Indexers
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ProwlarrStep', () => {
|
||||
it('shows validation errors when required fields are missing', async () => {
|
||||
const { ProwlarrStep } = await import('@/app/setup/steps/ProwlarrStep');
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(
|
||||
<ProwlarrStep
|
||||
prowlarrUrl=""
|
||||
prowlarrApiKey=""
|
||||
onUpdate={vi.fn()}
|
||||
onNext={onNext}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(screen.getByText('Please enter Prowlarr URL and API key')).toBeInTheDocument();
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates indexers and proceeds when valid', async () => {
|
||||
const { ProwlarrStep } = await import('@/app/setup/steps/ProwlarrStep');
|
||||
const onUpdate = vi.fn();
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(
|
||||
<ProwlarrStep
|
||||
prowlarrUrl="http://localhost:9696"
|
||||
prowlarrApiKey="key"
|
||||
onUpdate={onUpdate}
|
||||
onNext={onNext}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set Indexers' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith('prowlarrIndexers', indexersMock);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Component: Registration Settings Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RegistrationSettingsStep } from '@/app/setup/steps/RegistrationSettingsStep';
|
||||
|
||||
const RegistrationHarness = ({
|
||||
onNext,
|
||||
onBack,
|
||||
initialValue = false,
|
||||
}: {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
initialValue?: boolean;
|
||||
}) => {
|
||||
const [requireAdminApproval, setRequireAdminApproval] = useState(initialValue);
|
||||
|
||||
return (
|
||||
<RegistrationSettingsStep
|
||||
requireAdminApproval={requireAdminApproval}
|
||||
onUpdate={(_, value) => setRequireAdminApproval(value)}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('RegistrationSettingsStep', () => {
|
||||
it('toggles admin approval and navigates', async () => {
|
||||
const onNext = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(<RegistrationHarness onNext={onNext} onBack={onBack} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByText('Auto-Approval Enabled')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleButton = buttons.find((button) => !['Back', 'Next'].includes(button.textContent || ''));
|
||||
expect(toggleButton).toBeDefined();
|
||||
fireEvent.click(toggleButton as HTMLButtonElement);
|
||||
expect(screen.getByText('Admin Approval Workflow')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Component: Setup Review Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReviewStep } from '@/app/setup/steps/ReviewStep';
|
||||
|
||||
const baseConfig = {
|
||||
backendMode: 'plex' as const,
|
||||
plexUrl: 'http://plex.local',
|
||||
plexLibraryId: 'plex-lib',
|
||||
absUrl: 'http://abs.local',
|
||||
absLibraryId: 'abs-lib',
|
||||
authMethod: 'oidc' as const,
|
||||
oidcProviderName: 'Auth',
|
||||
adminUsername: 'admin',
|
||||
prowlarrUrl: 'http://prowlarr.local',
|
||||
downloadClient: 'qbittorrent' as const,
|
||||
downloadClientUrl: 'http://qb.local',
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
bookdateConfigured: true,
|
||||
bookdateProvider: 'openai',
|
||||
bookdateModel: 'model-1',
|
||||
};
|
||||
|
||||
describe('ReviewStep', () => {
|
||||
it('renders Plex configuration and triggers actions', async () => {
|
||||
const onComplete = vi.fn();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<ReviewStep
|
||||
config={baseConfig}
|
||||
loading={false}
|
||||
error={null}
|
||||
onComplete={onComplete}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Plex Media Server')).toBeInTheDocument();
|
||||
expect(screen.getByText('BookDate AI Recommendations')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Complete Setup' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders Audiobookshelf config and error state', async () => {
|
||||
render(
|
||||
<ReviewStep
|
||||
config={{ ...baseConfig, backendMode: 'audiobookshelf', authMethod: 'both' }}
|
||||
loading={false}
|
||||
error="Something went wrong"
|
||||
onComplete={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Audiobookshelf')).toBeInTheDocument();
|
||||
expect(screen.getByText('OIDC + Manual Registration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Component: Setup Welcome Step Tests
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('WelcomeStep', () => {
|
||||
it('calls onNext when Get Started is clicked', async () => {
|
||||
const onNext = vi.fn();
|
||||
const { WelcomeStep } = await import('@/app/setup/steps/WelcomeStep');
|
||||
|
||||
render(<WelcomeStep onNext={onNext} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Get Started/i }));
|
||||
expect(onNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Component: Flag Config Row Tests
|
||||
* Documentation: documentation/phase3/ranking-algorithm.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||
import type { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
|
||||
|
||||
describe('FlagConfigRow', () => {
|
||||
it('updates name and modifier values and allows removal', () => {
|
||||
const onChange = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
const config: IndexerFlagConfig = { name: 'Freeleech', modifier: 20 };
|
||||
|
||||
render(<FlagConfigRow config={config} onChange={onChange} onRemove={onRemove} />);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Internal' } });
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '-15' } });
|
||||
fireEvent.click(screen.getByTitle('Remove flag rule'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ name: 'Internal', modifier: 20 });
|
||||
expect(onChange).toHaveBeenCalledWith({ name: 'Freeleech', modifier: -15 });
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows disqualification warning for large negative modifiers', () => {
|
||||
render(
|
||||
<FlagConfigRow
|
||||
config={{ name: 'Bad', modifier: -60 }}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Would disqualify/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Component: Available Indexer Row Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AvailableIndexerRow } from '@/components/admin/indexers/AvailableIndexerRow';
|
||||
|
||||
describe('AvailableIndexerRow', () => {
|
||||
const indexer = {
|
||||
id: 1,
|
||||
name: 'Test Indexer',
|
||||
protocol: 'torrent',
|
||||
supportsRss: true,
|
||||
};
|
||||
|
||||
it('renders an add button when the indexer is not added', () => {
|
||||
const onAdd = vi.fn();
|
||||
|
||||
render(<AvailableIndexerRow indexer={indexer} isAdded={false} onAdd={onAdd} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('Test Indexer')).toBeInTheDocument();
|
||||
expect(screen.getByText('torrent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the added state when already configured', () => {
|
||||
render(<AvailableIndexerRow indexer={indexer} isAdded onAdd={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Added')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Add' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Component: Category Tree View Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { CategoryTreeView } from '@/components/admin/indexers/CategoryTreeView';
|
||||
import { getChildIds } from '@/lib/utils/torrent-categories';
|
||||
|
||||
describe('CategoryTreeView', () => {
|
||||
it('selects parent and children when the parent is toggled', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<CategoryTreeView selectedCategories={[]} onChange={onChange} />);
|
||||
|
||||
const audioLabel = screen.getByText('Audio');
|
||||
const audioRow = audioLabel.closest('div')?.parentElement;
|
||||
if (!audioRow) {
|
||||
throw new Error('Audio parent row not found');
|
||||
}
|
||||
|
||||
fireEvent.click(within(audioRow).getByRole('switch'));
|
||||
|
||||
const audioChildren = getChildIds(3000);
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([3000, ...audioChildren])
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles a child category on and off', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(<CategoryTreeView selectedCategories={[]} onChange={onChange} />);
|
||||
|
||||
const audiobookLabel = screen.getByText('Audiobook');
|
||||
const audiobookRow = audiobookLabel.closest('div')?.parentElement;
|
||||
if (!audiobookRow) {
|
||||
throw new Error('Audiobook row not found');
|
||||
}
|
||||
|
||||
fireEvent.click(within(audiobookRow).getByRole('switch'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([3030]));
|
||||
});
|
||||
|
||||
it('disables child toggles when all children are selected', () => {
|
||||
const audioChildren = getChildIds(3000);
|
||||
|
||||
render(
|
||||
<CategoryTreeView
|
||||
selectedCategories={[3000, ...audioChildren]}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const audiobookLabel = screen.getByText('Audiobook');
|
||||
const audiobookRow = audiobookLabel.closest('div')?.parentElement;
|
||||
if (!audiobookRow) {
|
||||
throw new Error('Audiobook row not found');
|
||||
}
|
||||
|
||||
expect(within(audiobookRow).getByRole('switch')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Component: Delete Confirm Modal Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DeleteConfirmModal } from '@/components/admin/indexers/DeleteConfirmModal';
|
||||
|
||||
describe('DeleteConfirmModal', () => {
|
||||
it('confirms removal and closes the modal', () => {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
render(
|
||||
<DeleteConfirmModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
indexerName="TrackerOne"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Indexer' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes without confirming when canceled', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<DeleteConfirmModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
onConfirm={vi.fn()}
|
||||
indexerName="TrackerTwo"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Component: Indexer Card Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { IndexerCard } from '@/components/admin/indexers/IndexerCard';
|
||||
|
||||
describe('IndexerCard', () => {
|
||||
it('renders indexer info and triggers edit/delete actions', () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<IndexerCard
|
||||
indexer={{ id: 2, name: 'IndexerTwo', protocol: 'usenet' }}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('IndexerTwo')).toBeInTheDocument();
|
||||
expect(screen.getByText('usenet')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Edit indexer'));
|
||||
fireEvent.click(screen.getByTitle('Delete indexer'));
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Component: Indexer Config Modal Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { IndexerConfigModal } from '@/components/admin/indexers/IndexerConfigModal';
|
||||
|
||||
describe('IndexerConfigModal', () => {
|
||||
it('clamps numeric inputs and saves configuration', () => {
|
||||
const onSave = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<IndexerConfigModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
mode="add"
|
||||
indexer={{ id: 1, name: 'Prowlarr', protocol: 'torrent', supportsRss: true }}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
|
||||
const [priorityInput, seedingInput] = screen.getAllByRole('spinbutton');
|
||||
|
||||
fireEvent.change(priorityInput, { target: { value: '99' } });
|
||||
expect(priorityInput).toHaveValue(25);
|
||||
|
||||
fireEvent.change(seedingInput, { target: { value: '-5' } });
|
||||
expect(seedingInput).toHaveValue(0);
|
||||
|
||||
const rssToggle = screen.getByRole('checkbox');
|
||||
fireEvent.click(rssToggle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Prowlarr',
|
||||
priority: 25,
|
||||
seedingTimeMinutes: 0,
|
||||
rssEnabled: false,
|
||||
categories: expect.arrayContaining([3030]),
|
||||
})
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('validates that at least one category is selected', () => {
|
||||
const onSave = vi.fn();
|
||||
|
||||
render(
|
||||
<IndexerConfigModal
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
mode="add"
|
||||
indexer={{ id: 2, name: 'NoCats', protocol: 'torrent', supportsRss: true }}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
|
||||
const audiobookLabel = screen.getByText('Audiobook');
|
||||
const audiobookRow = audiobookLabel.closest('div')?.parentElement;
|
||||
if (!audiobookRow) {
|
||||
throw new Error('Audiobook row not found');
|
||||
}
|
||||
|
||||
fireEvent.click(within(audiobookRow).getByRole('switch'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' }));
|
||||
|
||||
expect(screen.getByText('At least one category must be selected')).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces RSS to false when the indexer does not support RSS', () => {
|
||||
const onSave = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<IndexerConfigModal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
mode="add"
|
||||
indexer={{ id: 3, name: 'NoRSS', protocol: 'torrent', supportsRss: false }}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
|
||||
const rssToggle = screen.getByRole('checkbox');
|
||||
expect(rssToggle).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 3,
|
||||
name: 'NoRSS',
|
||||
rssEnabled: false,
|
||||
})
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Component: Indexer Management Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/admin/indexers/IndexerConfigModal', () => ({
|
||||
IndexerConfigModal: ({ isOpen, mode, indexer, initialConfig, onSave, onClose }: any) => {
|
||||
if (!isOpen) return null;
|
||||
const priority = initialConfig?.priority ? initialConfig.priority + 1 : 10;
|
||||
return (
|
||||
<div data-testid="indexer-config-modal">
|
||||
<div data-testid="modal-mode">{mode}</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
onSave({
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
priority,
|
||||
seedingTimeMinutes: 0,
|
||||
rssEnabled: true,
|
||||
categories: [3030],
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('IndexerManagement', () => {
|
||||
const emptyIndexers: any[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchWithAuthMock.mockReset();
|
||||
});
|
||||
|
||||
it('fetches indexers in wizard mode and adds a configuration', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
indexers: [
|
||||
{ id: 1, name: 'IndexerA', protocol: 'torrent', supportsRss: true },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const onIndexersChange = vi.fn();
|
||||
|
||||
render(
|
||||
<IndexerManagement
|
||||
prowlarrUrl="http://prowlarr.local"
|
||||
prowlarrApiKey="apikey"
|
||||
mode="wizard"
|
||||
initialIndexers={emptyIndexers}
|
||||
onIndexersChange={onIndexersChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fetch Indexers' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-prowlarr', expect.any(Object));
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('IndexerA')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onIndexersChange.mock.calls.at(-1)?.[0] as any[] | undefined;
|
||||
expect(lastCall).toHaveLength(1);
|
||||
expect(lastCall?.[0]).toMatchObject({ id: 1, name: 'IndexerA' });
|
||||
});
|
||||
|
||||
expect(screen.getByText('Configured Indexers (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses authenticated fetch in settings mode', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
indexers: [
|
||||
{ id: 2, name: 'IndexerB', protocol: 'torrent', supportsRss: false },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<IndexerManagement
|
||||
prowlarrUrl="http://prowlarr.local"
|
||||
prowlarrApiKey="apikey"
|
||||
mode="settings"
|
||||
initialIndexers={emptyIndexers}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fetch Indexers' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/admin/settings/test-prowlarr',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('IndexerB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a configured indexer after confirmation', async () => {
|
||||
const onIndexersChange = vi.fn();
|
||||
|
||||
render(
|
||||
<IndexerManagement
|
||||
prowlarrUrl="http://prowlarr.local"
|
||||
prowlarrApiKey="apikey"
|
||||
mode="settings"
|
||||
initialIndexers={[
|
||||
{
|
||||
id: 5,
|
||||
name: 'ConfiguredIndexer',
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 0,
|
||||
rssEnabled: true,
|
||||
categories: [3030],
|
||||
},
|
||||
]}
|
||||
onIndexersChange={onIndexersChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Delete indexer'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Indexer' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall = onIndexersChange.mock.calls.at(-1)?.[0] as any[] | undefined;
|
||||
expect(lastCall).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Configured Indexers (0)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Component: Audiobook Card Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const createRequestMock = vi.hoisted(() => vi.fn());
|
||||
const authState = {
|
||||
user: null as null | { id: string; username: string },
|
||||
};
|
||||
|
||||
vi.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/audiobooks/AudiobookDetailsModal', () => ({
|
||||
AudiobookDetailsModal: ({ isOpen }: { isOpen: boolean }) => (
|
||||
<div data-testid="details-modal" data-open={String(isOpen)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img {...props} />,
|
||||
}));
|
||||
|
||||
const baseAudiobook = {
|
||||
asin: 'asin-1',
|
||||
title: 'Test Book',
|
||||
author: 'Author',
|
||||
};
|
||||
|
||||
describe('AudiobookCard', () => {
|
||||
beforeEach(() => {
|
||||
authState.user = null;
|
||||
createRequestMock.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('disables requests when no user is logged in', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} />);
|
||||
|
||||
const requestButton = screen.getByRole('button', { name: 'Login to Request' });
|
||||
expect(requestButton).toBeDisabled();
|
||||
expect(createRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a request and shows a success toast', async () => {
|
||||
authState.user = { id: 'user-1', username: 'user' };
|
||||
createRequestMock.mockResolvedValueOnce(undefined);
|
||||
|
||||
const onRequestSuccess = vi.fn();
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} onRequestSuccess={onRequestSuccess} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request' }));
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
await requestPromise;
|
||||
});
|
||||
|
||||
expect(createRequestMock).toHaveBeenCalledWith(baseAudiobook);
|
||||
expect(onRequestSuccess).toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByText(/Request created successfully/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(screen.queryByText(/Request created successfully/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows in-library state when available', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={{ ...baseAudiobook, isAvailable: true }} />);
|
||||
|
||||
expect(screen.getByText('In Your Library')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the details modal when the title is clicked', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} />);
|
||||
|
||||
expect(screen.getByTestId('details-modal')).toHaveAttribute('data-open', 'false');
|
||||
|
||||
fireEvent.click(screen.getByText('Test Book'));
|
||||
|
||||
expect(screen.getByTestId('details-modal')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('shows processing state for downloaded requests', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(
|
||||
<AudiobookCard
|
||||
audiobook={{ ...baseAudiobook, isRequested: true, requestStatus: 'downloaded' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Processing...' });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows pending approval status with requester name', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(
|
||||
<AudiobookCard
|
||||
audiobook={{
|
||||
...baseAudiobook,
|
||||
isRequested: true,
|
||||
requestStatus: 'awaiting_approval',
|
||||
requestedByUsername: 'alice',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Pending Approval \(alice\)/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a denied request state', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(
|
||||
<AudiobookCard
|
||||
audiobook={{ ...baseAudiobook, isRequested: true, requestStatus: 'denied' }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Request Denied' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows an error when a request fails', async () => {
|
||||
authState.user = { id: 'user-1', username: 'user' };
|
||||
createRequestMock.mockRejectedValueOnce(new Error('Request failed'));
|
||||
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request' }));
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestPromise;
|
||||
} catch {
|
||||
// Expected for this test.
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('Request failed')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(screen.queryByText('Request failed')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Component: Audiobook Details Modal Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useAuthMock = vi.hoisted(() => vi.fn());
|
||||
const useAudiobookDetailsMock = vi.hoisted(() => vi.fn());
|
||||
const createRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
||||
useAudiobookDetails: (asin: string | null) => useAudiobookDetailsMock(asin),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
InteractiveTorrentSearchModal: ({ isOpen }: { isOpen: boolean }) => (
|
||||
<div data-testid="interactive-modal" data-open={String(isOpen)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img {...props} />,
|
||||
}));
|
||||
|
||||
const audiobookDetails = {
|
||||
asin: 'ASIN123',
|
||||
title: 'Detail Book',
|
||||
author: 'Detail Author',
|
||||
description: 'Summary',
|
||||
rating: 4.2,
|
||||
durationMinutes: 320,
|
||||
releaseDate: '2023-01-01',
|
||||
genres: ['Fantasy'],
|
||||
};
|
||||
|
||||
describe('AudiobookDetailsModal', () => {
|
||||
beforeEach(() => {
|
||||
useAuthMock.mockReturnValue({ user: { id: 'user-1', username: 'user' } });
|
||||
useAudiobookDetailsMock.mockReturnValue({
|
||||
audiobook: audiobookDetails,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
createRequestMock.mockReset();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders audiobook details and closes when requested', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Detail Book')).toBeInTheDocument();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close modal' }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates requests and auto-closes after success', async () => {
|
||||
vi.useFakeTimers();
|
||||
createRequestMock.mockResolvedValueOnce(undefined);
|
||||
const onClose = vi.fn();
|
||||
const onRequestSuccess = vi.fn();
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onRequestSuccess={onRequestSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
const requestButton = screen.getByRole('button', { name: 'Request Audiobook' });
|
||||
fireEvent.click(requestButton);
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
await requestPromise;
|
||||
});
|
||||
|
||||
expect(onRequestSuccess).toHaveBeenCalled();
|
||||
expect(screen.getByText(/Request created successfully/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('copies the ASIN to the clipboard', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
const asinButton = screen.getByText('ASIN123');
|
||||
await act(async () => {
|
||||
fireEvent.click(asinButton.closest('button') as HTMLButtonElement);
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ASIN123');
|
||||
});
|
||||
|
||||
it('shows an error state when details fail to load', async () => {
|
||||
useAudiobookDetailsMock.mockReturnValue({
|
||||
audiobook: null,
|
||||
isLoading: false,
|
||||
error: 'boom',
|
||||
});
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Failed to load audiobook details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows availability state and hides interactive search when available', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
isAvailable={true}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Available in Your Library')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Interactive Search')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows pending approval status with requester name', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
isRequested={true}
|
||||
requestStatus="awaiting_approval"
|
||||
requestedByUsername="alice"
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole('button', { name: /Pending Approval \(alice\)/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a denied request state', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
isRequested={true}
|
||||
requestStatus="denied"
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole('button', { name: 'Request Denied' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows Not Found when rating is missing', async () => {
|
||||
useAudiobookDetailsMock.mockReturnValue({
|
||||
audiobook: { ...audiobookDetails, rating: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Not Found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens interactive search when requested', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.queryByTestId('interactive-modal')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Interactive Search'));
|
||||
|
||||
expect(screen.getByTestId('interactive-modal')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('shows request error and clears it after timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
createRequestMock.mockRejectedValueOnce(new Error('Request failed'));
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request Audiobook' }));
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestPromise;
|
||||
} catch {
|
||||
// Expected for this test.
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('Request failed')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Request failed')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Component: Audiobook Grid Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import path from 'path';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockAudiobookCard = () => {
|
||||
vi.doMock(path.resolve('src/components/audiobooks/AudiobookCard.tsx'), () => ({
|
||||
AudiobookCard: ({ audiobook }: { audiobook: any }) => (
|
||||
<div data-testid="audiobook-card">{audiobook.asin}</div>
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
describe('AudiobookGrid', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockAudiobookCard();
|
||||
});
|
||||
|
||||
it('renders skeleton cards when loading', async () => {
|
||||
const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid');
|
||||
|
||||
const { container } = render(<AudiobookGrid audiobooks={[]} isLoading={true} />);
|
||||
|
||||
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('shows the empty message when there are no results', async () => {
|
||||
const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid');
|
||||
|
||||
render(<AudiobookGrid audiobooks={[]} isLoading={false} emptyMessage="Nothing found" />);
|
||||
|
||||
expect(screen.getByText('Nothing found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies grid classes based on card size', async () => {
|
||||
const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid');
|
||||
|
||||
const { container } = render(
|
||||
<AudiobookGrid
|
||||
audiobooks={[{ asin: 'a1', title: 'Book', author: 'Author' }]}
|
||||
cardSize={9}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('div')?.className).toContain('grid-cols-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Component: Protected Route Component Tests
|
||||
* Documentation: documentation/frontend/routing-auth.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../helpers/render';
|
||||
import { routerMock } from '../../helpers/mock-next-navigation';
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
let ProtectedRoute: typeof import('@/components/auth/ProtectedRoute').ProtectedRoute;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ ProtectedRoute } = await import('@/components/auth/ProtectedRoute'));
|
||||
});
|
||||
|
||||
it('shows loading state while auth is initializing', async () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute>
|
||||
<div>Protected Content</div>
|
||||
</ProtectedRoute>,
|
||||
{ auth: { isLoading: true } }
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to login with return URL', async () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute>
|
||||
<div>Protected Content</div>
|
||||
</ProtectedRoute>,
|
||||
{ auth: { user: null, isLoading: false }, pathname: '/requests' }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/login?redirect=%2Frequests');
|
||||
});
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects non-admin users when admin access is required', async () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute requireAdmin>
|
||||
<div>Admin Content</div>
|
||||
</ProtectedRoute>,
|
||||
{
|
||||
auth: {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'reader',
|
||||
role: 'user',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(routerMock.push).toHaveBeenCalledWith('/');
|
||||
});
|
||||
expect(screen.queryByText('Admin Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children for authenticated admins', async () => {
|
||||
renderWithProviders(
|
||||
<ProtectedRoute requireAdmin>
|
||||
<div>Admin Content</div>
|
||||
</ProtectedRoute>,
|
||||
{
|
||||
auth: {
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText('Admin Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Component: BookDate Book Picker Modal Tests
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const books = [
|
||||
{ id: 'book-1', title: 'First Book', author: 'Author One', coverUrl: null },
|
||||
{ id: 'book-2', title: 'Second Book', author: 'Author Two', coverUrl: null },
|
||||
];
|
||||
|
||||
describe('BookPickerModal', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads books and confirms the selection', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ books }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-123');
|
||||
const onConfirm = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal');
|
||||
|
||||
render(
|
||||
<BookPickerModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
selectedIds={[]}
|
||||
onConfirm={onConfirm}
|
||||
maxSelection={5}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/library', {
|
||||
headers: { Authorization: 'Bearer token-123' },
|
||||
});
|
||||
});
|
||||
|
||||
const firstBookButton = await screen.findByRole('button', { name: /First Book/ });
|
||||
fireEvent.click(firstBookButton);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Confirm Selection/ }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(['book-1']);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables additional selections once the max is reached', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ books }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-456');
|
||||
const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal');
|
||||
|
||||
render(
|
||||
<BookPickerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
selectedIds={[]}
|
||||
onConfirm={vi.fn()}
|
||||
maxSelection={1}
|
||||
/>
|
||||
);
|
||||
|
||||
const firstBookButton = await screen.findByRole('button', { name: /First Book/ });
|
||||
fireEvent.click(firstBookButton);
|
||||
|
||||
expect(screen.getByText(/Maximum reached/)).toBeInTheDocument();
|
||||
|
||||
const secondBookButton = screen.getByRole('button', { name: /Second Book/ });
|
||||
expect(secondBookButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows an error when loading books fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal');
|
||||
|
||||
render(
|
||||
<BookPickerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
selectedIds={[]}
|
||||
onConfirm={vi.fn()}
|
||||
maxSelection={5}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Failed to load library books')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty search state when no books match', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ books }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal');
|
||||
|
||||
render(
|
||||
<BookPickerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
selectedIds={[]}
|
||||
onConfirm={vi.fn()}
|
||||
maxSelection={5}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: /First Book/ });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search books...'), { target: { value: 'missing' } });
|
||||
|
||||
expect(screen.getByText('No books match your search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears selection and disables confirm', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ books }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal');
|
||||
|
||||
render(
|
||||
<BookPickerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
selectedIds={['book-1']}
|
||||
onConfirm={vi.fn()}
|
||||
maxSelection={5}
|
||||
/>
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: /First Book/ });
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'Clear Selection' });
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Confirm Selection/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Component: BookDate Card Stack Tests
|
||||
* Documentation: documentation/features/bookdate-animations.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/components/bookdate/RecommendationCard', () => ({
|
||||
RecommendationCard: ({
|
||||
recommendation,
|
||||
onSwipe,
|
||||
stackPosition,
|
||||
isDraggable,
|
||||
}: {
|
||||
recommendation: { id: string; title: string };
|
||||
onSwipe: (action: 'left' | 'right' | 'up') => void;
|
||||
stackPosition: number;
|
||||
isDraggable: boolean;
|
||||
}) => (
|
||||
<button
|
||||
data-testid={`card-${recommendation.id}`}
|
||||
data-stack={stackPosition}
|
||||
data-draggable={String(isDraggable)}
|
||||
onClick={() => onSwipe('left')}
|
||||
>
|
||||
{recommendation.title}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const recommendations = [
|
||||
{ id: 'rec-1', title: 'Rec One' },
|
||||
{ id: 'rec-2', title: 'Rec Two' },
|
||||
{ id: 'rec-3', title: 'Rec Three' },
|
||||
{ id: 'rec-4', title: 'Rec Four' },
|
||||
];
|
||||
|
||||
describe('CardStack', () => {
|
||||
it('renders up to three cards and only the top card is draggable', async () => {
|
||||
const { CardStack } = await import('@/components/bookdate/CardStack');
|
||||
|
||||
render(
|
||||
<CardStack
|
||||
recommendations={recommendations}
|
||||
currentIndex={0}
|
||||
onSwipe={vi.fn()}
|
||||
onSwipeComplete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('card-rec-1')).toHaveAttribute('data-stack', '0');
|
||||
expect(screen.getByTestId('card-rec-1')).toHaveAttribute('data-draggable', 'true');
|
||||
expect(screen.getByTestId('card-rec-2')).toHaveAttribute('data-stack', '1');
|
||||
expect(screen.getByTestId('card-rec-3')).toHaveAttribute('data-stack', '2');
|
||||
expect(screen.queryByTestId('card-rec-4')).toBeNull();
|
||||
});
|
||||
|
||||
it('locks swipes during animations and calls onSwipeComplete', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSwipe = vi.fn();
|
||||
const onSwipeComplete = vi.fn();
|
||||
const { CardStack } = await import('@/components/bookdate/CardStack');
|
||||
|
||||
render(
|
||||
<CardStack
|
||||
recommendations={recommendations}
|
||||
currentIndex={0}
|
||||
onSwipe={onSwipe}
|
||||
onSwipeComplete={onSwipeComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
const topCard = screen.getByTestId('card-rec-1');
|
||||
fireEvent.click(topCard);
|
||||
fireEvent.click(topCard);
|
||||
|
||||
expect(onSwipe).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(750);
|
||||
});
|
||||
expect(onSwipeComplete).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Component: BookDate Loading Screen Tests
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/components/layout/Header', () => ({
|
||||
Header: () => <div data-testid="header" />,
|
||||
}));
|
||||
|
||||
describe('LoadingScreen', () => {
|
||||
it('renders the loading message and header', async () => {
|
||||
const { LoadingScreen } = await import('@/components/bookdate/LoadingScreen');
|
||||
|
||||
render(<LoadingScreen />);
|
||||
|
||||
expect(screen.getByTestId('header')).toBeInTheDocument();
|
||||
expect(screen.getByText('Finding your next great listen...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Component: BookDate Recommendation Card Tests
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const swipeHandlers: {
|
||||
onSwiping?: (eventData: { deltaX: number; deltaY: number }) => void;
|
||||
onSwiped?: (eventData: { deltaX: number; deltaY: number }) => void;
|
||||
} = {};
|
||||
|
||||
vi.mock('react-swipeable', () => ({
|
||||
useSwipeable: (handlers: any) => {
|
||||
swipeHandlers.onSwiping = handlers.onSwiping;
|
||||
swipeHandlers.onSwiped = handlers.onSwiped;
|
||||
return {};
|
||||
},
|
||||
}));
|
||||
|
||||
const recommendation = {
|
||||
title: 'Sample Book',
|
||||
author: 'Sample Author',
|
||||
narrator: 'Sample Narrator',
|
||||
rating: 4.5,
|
||||
description: 'A sample description',
|
||||
aiReason: 'Because it matches your tastes.',
|
||||
};
|
||||
|
||||
describe('RecommendationCard', () => {
|
||||
beforeEach(() => {
|
||||
swipeHandlers.onSwiping = undefined;
|
||||
swipeHandlers.onSwiped = undefined;
|
||||
});
|
||||
|
||||
it('shows the request toast and triggers a request action', async () => {
|
||||
const onSwipe = vi.fn();
|
||||
const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard');
|
||||
|
||||
render(<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} />);
|
||||
|
||||
const requestButtons = screen.getAllByRole('button', { name: /Request/ });
|
||||
fireEvent.click(requestButtons[0]);
|
||||
|
||||
expect(screen.getByText('Request "Sample Book"?')).toBeInTheDocument();
|
||||
const toastRequestButtons = screen.getAllByRole('button', { name: /Request/ });
|
||||
fireEvent.click(toastRequestButtons[toastRequestButtons.length - 1]);
|
||||
|
||||
expect(onSwipe).toHaveBeenCalledWith('right', false);
|
||||
});
|
||||
|
||||
it('marks a recommendation as liked from the toast', async () => {
|
||||
const onSwipe = vi.fn();
|
||||
const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard');
|
||||
|
||||
render(<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} />);
|
||||
|
||||
const requestButtons = screen.getAllByRole('button', { name: /Request/ });
|
||||
fireEvent.click(requestButtons[0]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Mark as Liked' }));
|
||||
|
||||
expect(onSwipe).toHaveBeenCalledWith('right', true);
|
||||
});
|
||||
|
||||
it('triggers dislike and dismiss actions from desktop buttons', async () => {
|
||||
const onSwipe = vi.fn();
|
||||
const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard');
|
||||
|
||||
render(<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Not Interested/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Dismiss/ }));
|
||||
|
||||
expect(onSwipe).toHaveBeenCalledWith('left');
|
||||
expect(onSwipe).toHaveBeenCalledWith('up');
|
||||
});
|
||||
|
||||
it('shows drag overlays based on swipe direction', async () => {
|
||||
const onSwipe = vi.fn();
|
||||
const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard');
|
||||
|
||||
render(<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} />);
|
||||
|
||||
act(() => {
|
||||
swipeHandlers.onSwiping?.({ deltaX: -80, deltaY: 0 });
|
||||
});
|
||||
|
||||
expect(screen.getByText('Dislike')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers an upward swipe from gesture handling', async () => {
|
||||
const onSwipe = vi.fn();
|
||||
const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard');
|
||||
|
||||
render(<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} />);
|
||||
|
||||
act(() => {
|
||||
swipeHandlers.onSwiped?.({ deltaX: 0, deltaY: -150 });
|
||||
});
|
||||
|
||||
expect(onSwipe).toHaveBeenCalledWith('up');
|
||||
});
|
||||
|
||||
it('ignores swipe gestures when not draggable', async () => {
|
||||
const onSwipe = vi.fn();
|
||||
const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard');
|
||||
|
||||
render(
|
||||
<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} isDraggable={false} />
|
||||
);
|
||||
|
||||
act(() => {
|
||||
swipeHandlers.onSwiped?.({ deltaX: 150, deltaY: 0 });
|
||||
});
|
||||
|
||||
expect(onSwipe).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Request "Sample Book"/)).toBeNull();
|
||||
});
|
||||
|
||||
it('hides desktop actions when not the top card', async () => {
|
||||
const onSwipe = vi.fn();
|
||||
const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard');
|
||||
|
||||
render(
|
||||
<RecommendationCard recommendation={recommendation} onSwipe={onSwipe} stackPosition={1} />
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Not Interested/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Component: BookDate Settings Widget Tests
|
||||
* Documentation: documentation/features/bookdate.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/components/bookdate/BookPickerModal', () => ({
|
||||
BookPickerModal: ({ isOpen, onConfirm }: { isOpen: boolean; onConfirm: (ids: string[]) => void }) =>
|
||||
isOpen ? (
|
||||
<div data-testid="book-picker">
|
||||
<button onClick={() => onConfirm(['book-1'])}>Select Book</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('SettingsWidget', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads preferences and populates the form', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
libraryScope: 'rated',
|
||||
favoriteBookIds: ['book-1'],
|
||||
customPrompt: 'Custom prompt',
|
||||
backendCapabilities: { supportsRatings: true },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-789');
|
||||
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
|
||||
|
||||
render(<SettingsWidget isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/preferences', {
|
||||
headers: { Authorization: 'Bearer token-789' },
|
||||
});
|
||||
});
|
||||
|
||||
const ratedRadio = await screen.findByRole('radio', { name: /Rated Books Only/ });
|
||||
expect(ratedRadio).toBeChecked();
|
||||
expect(screen.getByDisplayValue('Custom prompt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires favorites selection before saving', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
libraryScope: 'full',
|
||||
favoriteBookIds: [],
|
||||
customPrompt: '',
|
||||
backendCapabilities: { supportsRatings: true },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-000');
|
||||
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
|
||||
|
||||
render(<SettingsWidget isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const favoritesRadio = await screen.findByRole('radio', { name: /Pick my favorites/ });
|
||||
fireEvent.click(favoritesRadio);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save Preferences/ }));
|
||||
|
||||
expect(await screen.findByText('Please select at least 1 favorite book')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves onboarding preferences and calls completion handlers', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
libraryScope: 'full',
|
||||
favoriteBookIds: [],
|
||||
customPrompt: '',
|
||||
backendCapabilities: { supportsRatings: true },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-onboarding');
|
||||
const onClose = vi.fn();
|
||||
const onOnboardingComplete = vi.fn();
|
||||
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
|
||||
|
||||
render(
|
||||
<SettingsWidget
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
isOnboarding={true}
|
||||
onOnboardingComplete={onOnboardingComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
const letsGoButton = await screen.findByRole('button', { name: "Let's Go!" });
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(letsGoButton);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(requestBody.onboardingComplete).toBe(true);
|
||||
expect(requestBody.customPrompt).toBeNull();
|
||||
expect(requestBody.libraryScope).toBe('full');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(onOnboardingComplete).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('hides rated scope when backend does not support ratings', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
libraryScope: 'full',
|
||||
favoriteBookIds: [],
|
||||
customPrompt: '',
|
||||
backendCapabilities: { supportsRatings: false },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-no-ratings');
|
||||
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
|
||||
|
||||
render(<SettingsWidget isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('radio', { name: /Rated Books Only/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an error when loading preferences fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-fail');
|
||||
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
|
||||
|
||||
render(<SettingsWidget isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('Failed to load preferences')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves preferences and clears success message after delay', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
libraryScope: 'full',
|
||||
favoriteBookIds: [],
|
||||
customPrompt: '',
|
||||
backendCapabilities: { supportsRatings: true },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token-save');
|
||||
const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget');
|
||||
|
||||
render(<SettingsWidget isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const promptInput = await screen.findByLabelText(/Special Requests/);
|
||||
fireEvent.change(promptInput, { target: { value: ' trimmed ' } });
|
||||
|
||||
vi.useFakeTimers();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Preferences' }));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(requestBody.customPrompt).toBe('trimmed');
|
||||
expect(requestBody.onboardingComplete).toBeUndefined();
|
||||
|
||||
expect(screen.getByText('Preferences saved successfully!')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Preferences saved successfully!')).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Component: Header Component Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../helpers/render';
|
||||
|
||||
describe('Header', () => {
|
||||
let Header: typeof import('@/components/layout/Header').Header;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ Header } = await import('@/components/layout/Header'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders login button and opens Plex auth window', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
|
||||
if (input === '/api/version') {
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({ success: true, authUrl: 'https://plex.example/login' }),
|
||||
});
|
||||
});
|
||||
const openMock = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, { auth: { user: null, isLoading: false } });
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /login with plex/i });
|
||||
await userEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/auth/plex/login', { method: 'POST' });
|
||||
});
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
'https://plex.example/login',
|
||||
'plex-auth',
|
||||
'width=600,height=700'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders admin navigation and user menu actions for local users', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, {
|
||||
auth: {
|
||||
user: {
|
||||
id: 'admin-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
authProvider: 'local',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Admin' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'My Requests' })).toBeInTheDocument();
|
||||
|
||||
const userButton = screen.getByText('admin').closest('button');
|
||||
expect(userButton).not.toBeNull();
|
||||
await userEvent.click(userButton as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Change Password')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows BookDate link and avatar when BookDate is enabled', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
|
||||
if (input === '/api/version') {
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
}
|
||||
if (input === '/api/bookdate/config') {
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
config: { isVerified: true, isEnabled: true },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, {
|
||||
auth: {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'reader',
|
||||
role: 'user',
|
||||
avatarUrl: '/avatar.png',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('link', { name: 'BookDate' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByAltText('reader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('logs out from the user menu and shows initials fallback', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
const logoutMock = vi.fn();
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, {
|
||||
auth: {
|
||||
user: {
|
||||
id: 'user-2',
|
||||
plexId: 'plex-2',
|
||||
username: 'alice',
|
||||
role: 'user',
|
||||
authProvider: 'plex',
|
||||
},
|
||||
logout: logoutMock,
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText(/^A$/)).toBeInTheDocument();
|
||||
|
||||
const userButton = screen.getByText('alice').closest('button');
|
||||
expect(userButton).not.toBeNull();
|
||||
await userEvent.click(userButton as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Logout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText('Logout'));
|
||||
|
||||
expect(logoutMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles the mobile menu and closes after navigation', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, { auth: { user: null, isLoading: false } });
|
||||
|
||||
const initialHomeLinks = screen.getAllByRole('link', { name: 'Home' }).length;
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Toggle menu' }));
|
||||
|
||||
const openHomeLinks = screen.getAllByRole('link', { name: 'Home' });
|
||||
expect(openHomeLinks).toHaveLength(initialHomeLinks + 1);
|
||||
|
||||
await userEvent.click(openHomeLinks[openHomeLinks.length - 1]);
|
||||
expect(screen.getAllByRole('link', { name: 'Home' })).toHaveLength(initialHomeLinks);
|
||||
});
|
||||
|
||||
it('hides BookDate when config check fails', async () => {
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
|
||||
if (input === '/api/version') {
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
|
||||
});
|
||||
}
|
||||
if (input === '/api/bookdate/config') {
|
||||
return Promise.reject(new Error('boom'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderWithProviders(<Header />, {
|
||||
auth: {
|
||||
user: {
|
||||
id: 'user-3',
|
||||
plexId: 'plex-3',
|
||||
username: 'reader',
|
||||
role: 'user',
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(errorMock).toHaveBeenCalledWith('Failed to check BookDate config:', expect.any(Error));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'BookDate' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Component: Interactive Torrent Search Modal Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const searchByRequestMock = vi.hoisted(() => vi.fn());
|
||||
const selectTorrentMock = vi.hoisted(() => vi.fn());
|
||||
const searchByAudiobookMock = vi.hoisted(() => vi.fn());
|
||||
const requestWithTorrentMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useInteractiveSearch: () => ({
|
||||
searchTorrents: searchByRequestMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useSelectTorrent: () => ({
|
||||
selectTorrent: selectTorrentMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useSearchTorrents: () => ({
|
||||
searchTorrents: searchByAudiobookMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
useRequestWithTorrent: () => ({
|
||||
requestWithTorrent: requestWithTorrentMock,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const baseResult = {
|
||||
guid: 'torrent-1',
|
||||
rank: 1,
|
||||
title: 'Test Torrent',
|
||||
size: 2.4 * 1024 ** 3,
|
||||
score: 88,
|
||||
bonusPoints: 5,
|
||||
seeders: 42,
|
||||
indexer: 'ProIndexer',
|
||||
format: 'M4B',
|
||||
infoUrl: 'https://example.com/torrent',
|
||||
};
|
||||
|
||||
describe('InteractiveTorrentSearchModal', () => {
|
||||
it('searches by request id on open and confirms download', async () => {
|
||||
searchByRequestMock.mockResolvedValueOnce([baseResult]);
|
||||
selectTorrentMock.mockResolvedValueOnce(undefined);
|
||||
const onClose = vi.fn();
|
||||
const onSuccess = vi.fn();
|
||||
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
|
||||
|
||||
render(
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onSuccess={onSuccess}
|
||||
requestId="req-123"
|
||||
audiobook={{ title: 'Test Book', author: 'Test Author' }}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchByRequestMock).toHaveBeenCalledWith('req-123', undefined);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Test Torrent')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
const downloadButtons = screen.getAllByRole('button', { name: 'Download' });
|
||||
fireEvent.click(downloadButtons[downloadButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult);
|
||||
});
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('searches by audiobook data and requests with torrent', async () => {
|
||||
searchByAudiobookMock.mockResolvedValueOnce([baseResult]);
|
||||
requestWithTorrentMock.mockResolvedValueOnce(undefined);
|
||||
const onClose = vi.fn();
|
||||
const fullAudiobook = { asin: 'ASIN-1', title: 'Test Book', author: 'Test Author' };
|
||||
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
|
||||
|
||||
render(
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
audiobook={{ title: 'Test Book', author: 'Test Author' }}
|
||||
fullAudiobook={fullAudiobook as any}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchByAudiobookMock).toHaveBeenCalledWith('Test Book', 'Test Author', 'ASIN-1');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
const downloadButtons = screen.getAllByRole('button', { name: 'Download' });
|
||||
fireEvent.click(downloadButtons[downloadButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestWithTorrentMock).toHaveBeenCalledWith(fullAudiobook, baseResult);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses a custom title when pressing Enter', async () => {
|
||||
searchByRequestMock.mockResolvedValueOnce([]);
|
||||
searchByRequestMock.mockResolvedValueOnce([]);
|
||||
const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal');
|
||||
|
||||
render(
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
requestId="req-456"
|
||||
audiobook={{ title: 'Original Title', author: 'Author' }}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchByRequestMock).toHaveBeenCalledWith('req-456', undefined);
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter book title to search...');
|
||||
fireEvent.change(input, { target: { value: 'Custom Title' } });
|
||||
fireEvent.keyPress(input, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Component: Request Card Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const cancelRequestMock = vi.hoisted(() => vi.fn());
|
||||
const manualSearchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useCancelRequest: () => ({ cancelRequest: cancelRequestMock, isLoading: false }),
|
||||
useManualSearch: () => ({ triggerManualSearch: manualSearchMock, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
InteractiveTorrentSearchModal: ({
|
||||
isOpen,
|
||||
requestId,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
requestId?: string;
|
||||
}) => (
|
||||
<div data-testid="interactive-modal" data-open={String(isOpen)} data-request-id={requestId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img {...props} />,
|
||||
}));
|
||||
|
||||
const baseRequest = {
|
||||
id: 'req-1',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
audiobook: {
|
||||
id: 'book-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
},
|
||||
};
|
||||
|
||||
describe('RequestCard', () => {
|
||||
beforeEach(() => {
|
||||
cancelRequestMock.mockReset();
|
||||
manualSearchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows progress and active indicator for downloads', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
render(
|
||||
<RequestCard
|
||||
request={{
|
||||
...baseRequest,
|
||||
status: 'downloading',
|
||||
progress: 45,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Downloading')).toBeInTheDocument();
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('45%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles the error message for failed requests', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
render(
|
||||
<RequestCard
|
||||
request={{
|
||||
...baseRequest,
|
||||
status: 'failed',
|
||||
errorMessage: 'Failure details',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show error' }));
|
||||
expect(await screen.findByText('Failure details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers manual search, interactive search, and cancel actions', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
manualSearchMock.mockResolvedValueOnce(undefined);
|
||||
cancelRequestMock.mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<RequestCard request={baseRequest} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manual Search' }));
|
||||
await waitFor(() => {
|
||||
expect(manualSearchMock).toHaveBeenCalledWith('req-1');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Interactive Search' }));
|
||||
expect(screen.getByTestId('interactive-modal')).toHaveAttribute('data-open', 'true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => {
|
||||
expect(cancelRequestMock).toHaveBeenCalledWith('req-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows setup indicator when progress is zero', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
render(
|
||||
<RequestCard
|
||||
request={{
|
||||
...baseRequest,
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Setting up...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides action buttons when showActions is false', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
render(<RequestCard request={baseRequest} showActions={false} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Manual Search' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
|
||||
});
|
||||
|
||||
it('alerts when manual search fails', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
manualSearchMock.mockRejectedValueOnce(new Error('Search failed'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
render(<RequestCard request={baseRequest} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manual Search' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('Search failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not cancel when confirmation is declined', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<RequestCard request={baseRequest} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cancelRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows completed timestamp when available', async () => {
|
||||
const { RequestCard } = await import('@/components/requests/RequestCard');
|
||||
|
||||
render(
|
||||
<RequestCard
|
||||
request={{
|
||||
...baseRequest,
|
||||
completedAt: new Date('2024-01-01T00:00:00Z').toISOString(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Completed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Component: Status Badge Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { StatusBadge } from '@/components/requests/StatusBadge';
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it('uses the initializing label for zero-progress downloads', () => {
|
||||
render(<StatusBadge status="downloading" progress={0} />);
|
||||
expect(screen.getByText('Initializing...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw status when unknown', () => {
|
||||
render(<StatusBadge status="custom_status" />);
|
||||
expect(screen.getByText('custom_status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Component: Card Size Controls Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('CardSizeControls', () => {
|
||||
beforeEach(() => {
|
||||
window.innerWidth = 1300;
|
||||
});
|
||||
|
||||
it('moves to the next visible size when zooming in or out', async () => {
|
||||
const onSizeChange = vi.fn();
|
||||
const { CardSizeControls } = await import('@/components/ui/CardSizeControls');
|
||||
|
||||
render(<CardSizeControls size={5} onSizeChange={onSizeChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }));
|
||||
expect(onSizeChange).toHaveBeenCalledWith(6);
|
||||
|
||||
onSizeChange.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }));
|
||||
expect(onSizeChange).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('disables zoom controls at the size boundaries', async () => {
|
||||
const onSizeChange = vi.fn();
|
||||
const { CardSizeControls } = await import('@/components/ui/CardSizeControls');
|
||||
|
||||
const { rerender } = render(<CardSizeControls size={1} onSizeChange={onSizeChange} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled();
|
||||
|
||||
rerender(<CardSizeControls size={9} onSizeChange={onSizeChange} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Component: Change Password Modal Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
|
||||
|
||||
describe('ChangePasswordModal', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows validation errors when required fields are missing', () => {
|
||||
render(<ChangePasswordModal isOpen onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change Password' }));
|
||||
|
||||
expect(screen.getByText('Current password is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('New password is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please confirm your new password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects submission when access token is missing', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<ChangePasswordModal isOpen onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Current Password'), {
|
||||
target: { value: 'old-password' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('New Password'), {
|
||||
target: { value: 'new-password' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
||||
target: { value: 'new-password' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change Password' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not authenticated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits successfully and auto-closes after showing success', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onClose = vi.fn();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
render(<ChangePasswordModal isOpen onClose={onClose} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Current Password'), {
|
||||
target: { value: 'old-password' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('New Password'), {
|
||||
target: { value: 'new-password' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
||||
target: { value: 'new-password' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change Password' }));
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/change-password',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
|
||||
expect(screen.getByText('Password changed successfully!')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows server error responses', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Invalid password' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'token');
|
||||
|
||||
render(<ChangePasswordModal isOpen onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Current Password'), {
|
||||
target: { value: 'old-password' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('New Password'), {
|
||||
target: { value: 'new-password' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
|
||||
target: { value: 'new-password' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change Password' }));
|
||||
|
||||
expect(await screen.findByText('Invalid password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Component: Modal Component Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
describe('Modal', () => {
|
||||
it('locks body scroll while open and closes on escape', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<Modal isOpen onClose={onClose} title="Test Modal">
|
||||
<div>Modal Content</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
expect(document.body.style.overflow).toBe('unset');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Component: Pagination Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('renders nothing when there is only one page', () => {
|
||||
const { container } = render(<Pagination currentPage={1} totalPages={1} onPageChange={vi.fn()} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders ellipses for large page ranges', () => {
|
||||
render(<Pagination currentPage={5} totalPages={10} onPageChange={vi.fn()} />);
|
||||
const ellipses = screen.getAllByText('...');
|
||||
expect(ellipses.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calls onPageChange for navigation controls', () => {
|
||||
const onPageChange = vi.fn();
|
||||
render(<Pagination currentPage={2} totalPages={5} onPageChange={onPageChange} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Next page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Page 4'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Component: Sticky Pagination Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
||||
|
||||
type ObserverEntry = {
|
||||
isIntersecting: boolean;
|
||||
intersectionRatio: number;
|
||||
target: Element;
|
||||
};
|
||||
|
||||
describe('StickyPagination', () => {
|
||||
const observers: { callback: IntersectionObserverCallback }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
class MockIntersectionObserver {
|
||||
callback: IntersectionObserverCallback;
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
(global as any).IntersectionObserver = MockIntersectionObserver;
|
||||
});
|
||||
|
||||
it('returns null when there is only one page', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const { container } = render(
|
||||
<StickyPagination
|
||||
currentPage={1}
|
||||
totalPages={1}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows and hides based on section and footer visibility', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const footerRef = { current: document.createElement('div') };
|
||||
|
||||
const { container } = render(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={5}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
footerRef={footerRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.2,
|
||||
target: sectionRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-100');
|
||||
|
||||
act(() => {
|
||||
observers[1].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.2,
|
||||
target: footerRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[1] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
});
|
||||
|
||||
it('handles navigation and jump input updates', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const onPageChange = vi.fn();
|
||||
|
||||
render(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={4}
|
||||
onPageChange={onPageChange}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Next page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
|
||||
const input = screen.getByLabelText('Current page') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '4' } });
|
||||
fireEvent.blur(input);
|
||||
expect(onPageChange).toHaveBeenCalledWith(4);
|
||||
|
||||
fireEvent.change(input, { target: { value: '99' } });
|
||||
fireEvent.blur(input);
|
||||
expect(input.value).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Component: Toast Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
|
||||
const ToastHarness = () => {
|
||||
const { success, error } = useToast();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => success('Saved', 1000)}>
|
||||
Add Success
|
||||
</button>
|
||||
<button type="button" onClick={() => error('Failed', 0)}>
|
||||
Add Error
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ToastProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('adds and auto-removes toasts after the duration', async () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<ToastHarness />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Success' }));
|
||||
expect(screen.getByText('Saved')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Saved')).toBeNull();
|
||||
});
|
||||
|
||||
it('removes a toast when the close button is clicked', () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<ToastHarness />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Error' }));
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(screen.queryByText('Failed')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Component: Version Badge Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { VersionBadge } from '@/components/ui/VersionBadge';
|
||||
|
||||
const originalCommit = process.env.NEXT_PUBLIC_GIT_COMMIT;
|
||||
|
||||
describe('VersionBadge', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (originalCommit === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_GIT_COMMIT;
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_GIT_COMMIT = originalCommit;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders short version from build-time commit', async () => {
|
||||
process.env.NEXT_PUBLIC_GIT_COMMIT = 'abcdef1234';
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<VersionBadge />);
|
||||
|
||||
expect(await screen.findByText('v.abcdef1')).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to API when build-time commit is unavailable', async () => {
|
||||
process.env.NEXT_PUBLIC_GIT_COMMIT = 'unknown';
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({ version: 'v.1.2.3' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<VersionBadge />);
|
||||
|
||||
expect(await screen.findByText('v.1.2.3')).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/version');
|
||||
});
|
||||
|
||||
it('shows dev version when API fetch fails', async () => {
|
||||
process.env.NEXT_PUBLIC_GIT_COMMIT = 'unknown';
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('down'));
|
||||
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(<VersionBadge />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('v.dev')).toBeInTheDocument();
|
||||
});
|
||||
expect(errorMock).toHaveBeenCalledWith('Failed to fetch version:', expect.any(Error));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Component: Authentication Context Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const isTokenExpiredMock = vi.hoisted(() => vi.fn());
|
||||
const getRefreshTimeMsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/jwt-client', () => ({
|
||||
isTokenExpired: isTokenExpiredMock,
|
||||
getRefreshTimeMs: getRefreshTimeMsMock,
|
||||
}));
|
||||
|
||||
function TestConsumer() {
|
||||
const { user, accessToken, isLoading, login, logout, refreshToken, setAuthData } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{String(isLoading)}</div>
|
||||
<div data-testid="user">{user?.username ?? 'none'}</div>
|
||||
<div data-testid="token">{accessToken ?? 'none'}</div>
|
||||
<button type="button" onClick={() => void login(123)}>
|
||||
login
|
||||
</button>
|
||||
<button type="button" onClick={logout}>
|
||||
logout
|
||||
</button>
|
||||
<button type="button" onClick={() => void refreshToken()}>
|
||||
refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthData({ id: 'user-99', plexId: 'plex-99', username: 'set-user', role: 'user' }, 'set-token')}
|
||||
>
|
||||
setAuth
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAuthProvider() {
|
||||
return render(
|
||||
<AuthProvider>
|
||||
<TestConsumer />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthProvider', () => {
|
||||
let locationStub: { href: string; pathname: string };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
isTokenExpiredMock.mockReturnValue(false);
|
||||
getRefreshTimeMsMock.mockReturnValue(300_000);
|
||||
|
||||
locationStub = { href: 'http://localhost/', pathname: '/' };
|
||||
vi.stubGlobal('location', locationStub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('restores session and refreshes user details with a valid token', async () => {
|
||||
const storedUser = {
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'old-user',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
localStorage.setItem('accessToken', 'access-token');
|
||||
localStorage.setItem('user', JSON.stringify(storedUser));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ user: { ...storedUser, username: 'fresh-user' } }),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('fresh-user'));
|
||||
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('access-token');
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/me',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer access-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const storedUserJson = JSON.parse(localStorage.getItem('user') ?? '{}') as { username?: string };
|
||||
expect(storedUserJson.username).toBe('fresh-user');
|
||||
});
|
||||
|
||||
it('refreshes the access token on mount when the access token is expired', async () => {
|
||||
isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired'));
|
||||
|
||||
localStorage.setItem('accessToken', 'expired-access');
|
||||
localStorage.setItem('refreshToken', 'refresh-token');
|
||||
localStorage.setItem('user', JSON.stringify({ id: 'user-2' }));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ accessToken: 'new-access' }),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('token')).toHaveTextContent('new-access'));
|
||||
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false');
|
||||
expect(localStorage.getItem('accessToken')).toBe('new-access');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clears stored auth data when both tokens are expired', async () => {
|
||||
isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired'));
|
||||
|
||||
localStorage.setItem('accessToken', 'expired-access');
|
||||
localStorage.setItem('refreshToken', 'expired-refresh');
|
||||
localStorage.setItem('user', JSON.stringify({ id: 'user-3' }));
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false'));
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
expect(localStorage.getItem('refreshToken')).toBeNull();
|
||||
expect(localStorage.getItem('user')).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores tokens and user data after a successful login', async () => {
|
||||
const loginUser = {
|
||||
id: 'user-4',
|
||||
plexId: 'plex-4',
|
||||
username: 'plex-user',
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
authorized: true,
|
||||
accessToken: 'login-access',
|
||||
refreshToken: 'login-refresh',
|
||||
user: loginUser,
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'login' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('plex-user'));
|
||||
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('login-access');
|
||||
expect(localStorage.getItem('accessToken')).toBe('login-access');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('login-refresh');
|
||||
});
|
||||
|
||||
it('logs out by clearing storage and redirecting to the login page', () => {
|
||||
localStorage.setItem('accessToken', 'access-token');
|
||||
localStorage.setItem('refreshToken', 'refresh-token');
|
||||
localStorage.setItem('user', JSON.stringify({ id: 'user-5' }));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'logout' }));
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
expect(localStorage.getItem('refreshToken')).toBeNull();
|
||||
expect(localStorage.getItem('user')).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/auth/logout', { method: 'POST' });
|
||||
expect(locationStub.href).toContain('/login');
|
||||
});
|
||||
|
||||
it('throws when useAuth is used outside the provider', () => {
|
||||
function BrokenConsumer() {
|
||||
useAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
expect(() => render(<BrokenConsumer />)).toThrow('useAuth must be used within an AuthProvider');
|
||||
});
|
||||
|
||||
it('sets auth data directly and updates state', async () => {
|
||||
renderAuthProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'setAuth' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('user')).toHaveTextContent('set-user');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('set-token');
|
||||
});
|
||||
|
||||
it('refreshes token when refreshToken is called', async () => {
|
||||
localStorage.setItem('refreshToken', 'refresh-token');
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ accessToken: 'refreshed-token' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'refresh' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('refreshed-token');
|
||||
});
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBe('refreshed-token');
|
||||
});
|
||||
|
||||
it('logs out when access token is removed in another tab', async () => {
|
||||
renderAuthProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'setAuth' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('set-token');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'accessToken', newValue: null }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('none');
|
||||
});
|
||||
|
||||
expect(locationStub.href).toContain('/login');
|
||||
});
|
||||
|
||||
it('syncs auth data when access token is added in another tab', async () => {
|
||||
localStorage.setItem('user', JSON.stringify({ id: 'user-sync', plexId: 'plex-sync', username: 'synced', role: 'user' }));
|
||||
|
||||
renderAuthProvider();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'accessToken', newValue: 'synced-token' }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('user')).toHaveTextContent('synced');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('token')).toHaveTextContent('synced-token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Component: Preferences Context Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { PreferencesProvider, usePreferences } from '@/contexts/PreferencesContext';
|
||||
|
||||
const TestConsumer = () => {
|
||||
const { cardSize, setCardSize } = usePreferences();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="size">{cardSize}</span>
|
||||
<button type="button" onClick={() => setCardSize(12)}>
|
||||
Set Large
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('PreferencesContext', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads card size from localStorage when valid', async () => {
|
||||
localStorage.setItem('preferences', JSON.stringify({ cardSize: 7 }));
|
||||
|
||||
render(
|
||||
<PreferencesProvider>
|
||||
<TestConsumer />
|
||||
</PreferencesProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('7');
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps card size updates and persists them', async () => {
|
||||
render(
|
||||
<PreferencesProvider>
|
||||
<TestConsumer />
|
||||
</PreferencesProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set Large' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('9');
|
||||
});
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('preferences') || '{}');
|
||||
expect(stored.cardSize).toBe(9);
|
||||
});
|
||||
|
||||
it('updates card size when a storage event is received', async () => {
|
||||
render(
|
||||
<PreferencesProvider>
|
||||
<TestConsumer />
|
||||
</PreferencesProvider>
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'preferences',
|
||||
newValue: JSON.stringify({ cardSize: 3 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Component: Auth Context Test Mock
|
||||
* Documentation: documentation/frontend/routing-auth.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
plexId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
authProvider?: string | null;
|
||||
}
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
user: null as MockUser | null,
|
||||
accessToken: null as string | null,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
setAuthData: vi.fn(),
|
||||
}));
|
||||
|
||||
export const setMockAuthState = (overrides: Partial<typeof authState>) => {
|
||||
Object.assign(authState, overrides);
|
||||
};
|
||||
|
||||
export const resetMockAuthState = () => {
|
||||
authState.user = null;
|
||||
authState.accessToken = null;
|
||||
authState.isLoading = false;
|
||||
authState.login.mockReset();
|
||||
authState.logout.mockReset();
|
||||
authState.refreshToken.mockReset();
|
||||
authState.setAuthData.mockReset();
|
||||
};
|
||||
|
||||
vi.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => authState,
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Component: Next Navigation Test Mock
|
||||
* Documentation: documentation/frontend/routing-auth.md
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
const router = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
}));
|
||||
|
||||
let pathname = '/';
|
||||
let searchParams = new URLSearchParams();
|
||||
|
||||
export const routerMock = router;
|
||||
|
||||
export const setMockPathname = (value: string) => {
|
||||
pathname = value;
|
||||
};
|
||||
|
||||
export const setMockSearchParams = (value: string | URLSearchParams) => {
|
||||
searchParams = typeof value === 'string' ? new URLSearchParams(value) : value;
|
||||
};
|
||||
|
||||
export const resetMockRouter = () => {
|
||||
router.push.mockReset();
|
||||
router.replace.mockReset();
|
||||
router.prefetch.mockReset();
|
||||
router.refresh.mockReset();
|
||||
router.back.mockReset();
|
||||
router.forward.mockReset();
|
||||
pathname = '/';
|
||||
searchParams = new URLSearchParams();
|
||||
};
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => router,
|
||||
usePathname: () => pathname,
|
||||
useSearchParams: () => searchParams,
|
||||
redirect: vi.fn(),
|
||||
notFound: vi.fn(),
|
||||
}));
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Component: Frontend Test Render Helpers
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { SWRConfig, type SWRConfiguration } from 'swr';
|
||||
import { resetMockAuthState, setMockAuthState, type MockUser } from './mock-auth';
|
||||
import { resetMockRouter, setMockPathname, setMockSearchParams } from './mock-next-navigation';
|
||||
|
||||
type RenderWithProvidersOptions = Omit<RenderOptions, 'wrapper'> & {
|
||||
auth?: Partial<{
|
||||
user: MockUser | null;
|
||||
accessToken: string | null;
|
||||
isLoading: boolean;
|
||||
login: (pinId: number) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshToken: () => Promise<void>;
|
||||
setAuthData: (user: MockUser, accessToken: string) => void;
|
||||
}>;
|
||||
pathname?: string;
|
||||
searchParams?: string | URLSearchParams;
|
||||
swr?: SWRConfiguration;
|
||||
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
|
||||
};
|
||||
|
||||
const createWrapper = (
|
||||
swr: SWRConfiguration | undefined,
|
||||
Wrapper: RenderWithProvidersOptions['wrapper']
|
||||
) => {
|
||||
return function WrapperComponent({ children }: { children: React.ReactNode }) {
|
||||
const content = Wrapper ? <Wrapper>{children}</Wrapper> : children;
|
||||
|
||||
return (
|
||||
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0, ...swr }}>
|
||||
{content}
|
||||
</SWRConfig>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const renderWithProviders = (
|
||||
ui: React.ReactElement,
|
||||
options: RenderWithProvidersOptions = {}
|
||||
) => {
|
||||
resetMockAuthState();
|
||||
resetMockRouter();
|
||||
|
||||
if (options.auth) {
|
||||
setMockAuthState(options.auth);
|
||||
}
|
||||
|
||||
if (options.pathname) {
|
||||
setMockPathname(options.pathname);
|
||||
}
|
||||
|
||||
if (options.searchParams) {
|
||||
setMockSearchParams(options.searchParams);
|
||||
}
|
||||
|
||||
const { wrapper, swr, ...renderOptions } = options;
|
||||
|
||||
return render(ui, {
|
||||
wrapper: createWrapper(swr, wrapper),
|
||||
...renderOptions,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Component: Audiobooks Hooks Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useSWRMock = vi.hoisted(() => vi.fn());
|
||||
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: useSWRMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
authenticatedFetcher: authenticatedFetcherMock,
|
||||
}));
|
||||
|
||||
const HookProbe = ({ label, value }: { label: string; value: any }) => (
|
||||
<div data-testid={label}>{JSON.stringify(value)}</div>
|
||||
);
|
||||
|
||||
describe('useAudiobooks hooks', () => {
|
||||
beforeEach(() => {
|
||||
useSWRMock.mockReset();
|
||||
authenticatedFetcherMock.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('builds the popular audiobooks endpoint and returns data', async () => {
|
||||
useSWRMock.mockReturnValue({
|
||||
data: { audiobooks: [{ asin: 'a1' }], totalPages: 3, totalCount: 30, hasMore: true },
|
||||
error: null,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { useAudiobooks } = await import('@/lib/hooks/useAudiobooks');
|
||||
|
||||
const Probe = () => {
|
||||
const result = useAudiobooks('popular', 10, 2);
|
||||
return <HookProbe label="popular" value={result} />;
|
||||
};
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
'/api/audiobooks/popular?page=2&limit=10',
|
||||
authenticatedFetcherMock,
|
||||
expect.objectContaining({ dedupingInterval: 60000 })
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(screen.getByTestId('popular').textContent || '{}');
|
||||
expect(parsed.audiobooks).toHaveLength(1);
|
||||
expect(parsed.totalPages).toBe(3);
|
||||
expect(parsed.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('skips search when the query is empty', async () => {
|
||||
useSWRMock.mockReturnValue({ data: null, error: null, isLoading: false });
|
||||
|
||||
const { useSearch } = await import('@/lib/hooks/useAudiobooks');
|
||||
|
||||
const Probe = () => {
|
||||
const result = useSearch('', 1);
|
||||
return <HookProbe label="search" value={result} />;
|
||||
};
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
null,
|
||||
authenticatedFetcherMock,
|
||||
expect.objectContaining({ dedupingInterval: 30000 })
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(screen.getByTestId('search').textContent || '{}');
|
||||
expect(parsed.isLoading).toBeFalsy();
|
||||
});
|
||||
|
||||
it('requests audiobook details when an ASIN is provided', async () => {
|
||||
useSWRMock.mockReturnValue({
|
||||
data: { audiobook: { asin: 'a2', title: 'Details' } },
|
||||
error: null,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const { useAudiobookDetails } = await import('@/lib/hooks/useAudiobooks');
|
||||
|
||||
const Probe = () => {
|
||||
const result = useAudiobookDetails('a2');
|
||||
return <HookProbe label="details" value={result} />;
|
||||
};
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
'/api/audiobooks/a2',
|
||||
authenticatedFetcherMock,
|
||||
expect.objectContaining({ dedupingInterval: 300000 })
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(screen.getByTestId('details').textContent || '{}');
|
||||
expect(parsed.audiobook.asin).toBe('a2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Component: Requests Hooks Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useAuthMock = vi.hoisted(() => vi.fn());
|
||||
const useSWRMock = vi.hoisted(() => vi.fn());
|
||||
const mutateMock = vi.hoisted(() => vi.fn());
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
}));
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
default: useSWRMock,
|
||||
mutate: mutateMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHookValue = <T,>(hook: () => T) => {
|
||||
let value: T;
|
||||
function Probe() {
|
||||
value = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return value!;
|
||||
};
|
||||
|
||||
const makeResponse = (body: any, ok = true) => ({
|
||||
ok,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('useRequests hooks', () => {
|
||||
beforeEach(() => {
|
||||
useAuthMock.mockReset();
|
||||
useSWRMock.mockReset();
|
||||
mutateMock.mockReset();
|
||||
fetchWithAuthMock.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('builds request list endpoints when authenticated', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
useSWRMock.mockReturnValue({ data: { requests: [] }, error: null, isLoading: false });
|
||||
|
||||
const { useRequests } = await import('@/lib/hooks/useRequests');
|
||||
|
||||
renderHookValue(() => useRequests('pending', 25, true));
|
||||
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
'/api/requests?status=pending&limit=25&myOnly=true',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ refreshInterval: 5000 })
|
||||
);
|
||||
});
|
||||
|
||||
it('builds request detail endpoints when authenticated', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
useSWRMock.mockReturnValue({ data: { request: { id: 'req-1' } }, error: null, isLoading: false });
|
||||
|
||||
const { useRequest } = await import('@/lib/hooks/useRequests');
|
||||
|
||||
renderHookValue(() => useRequest('req-1'));
|
||||
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
'/api/requests/req-1',
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ refreshInterval: 3000 })
|
||||
);
|
||||
});
|
||||
|
||||
it('creates requests and triggers revalidation', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-1' } }));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
const result = await hook.createRequest({ asin: 'a1', title: 'Book', author: 'Author' } as any);
|
||||
expect(result.id).toBe('req-1');
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/requests',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(mutateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces specific create request errors', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
hook.createRequest({ asin: 'a1', title: 'Book', author: 'Author' } as any)
|
||||
).rejects.toThrow('already in your Plex library');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels requests via the API', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-2' } }));
|
||||
|
||||
const { useCancelRequest } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useCancelRequest());
|
||||
|
||||
await act(async () => {
|
||||
await hook.cancelRequest('req-2');
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/requests/req-2',
|
||||
expect.objectContaining({ method: 'PATCH' })
|
||||
);
|
||||
});
|
||||
|
||||
it('triggers manual search for requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-3' } }));
|
||||
|
||||
const { useManualSearch } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useManualSearch());
|
||||
|
||||
await act(async () => {
|
||||
await hook.triggerManualSearch('req-3');
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/requests/req-3/manual-search',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('searches torrents interactively for a request', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ results: [{ guid: 't1' }] }));
|
||||
|
||||
const { useInteractiveSearch } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useInteractiveSearch());
|
||||
|
||||
await act(async () => {
|
||||
const results = await hook.searchTorrents('req-4', 'Custom');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/requests/req-4/interactive-search',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('selects torrents for existing requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-5' } }));
|
||||
|
||||
const { useSelectTorrent } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useSelectTorrent());
|
||||
|
||||
await act(async () => {
|
||||
await hook.selectTorrent('req-5', { title: 'Torrent' });
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/requests/req-5/select-torrent',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('searches torrents for new requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ results: [{ guid: 't2' }] }));
|
||||
|
||||
const { useSearchTorrents } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useSearchTorrents());
|
||||
|
||||
await act(async () => {
|
||||
const results = await hook.searchTorrents('Title', 'Author', 'asin');
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/audiobooks/search-torrents',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('requests torrents with audiobook payloads', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-6' } }));
|
||||
|
||||
const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests');
|
||||
const hook = renderHookValue(() => useRequestWithTorrent());
|
||||
|
||||
await act(async () => {
|
||||
await hook.requestWithTorrent({ asin: 'a1', title: 'Book', author: 'Author' } as any, { title: 'Torrent' });
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/audiobooks/request-with-torrent',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Component: API Utility Tests
|
||||
* Documentation: documentation/frontend/routing-auth.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const isTokenExpiredMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/jwt-client', () => ({
|
||||
isTokenExpired: isTokenExpiredMock,
|
||||
}));
|
||||
|
||||
describe('fetchWithAuth', () => {
|
||||
let locationStub: { href: string; pathname: string };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
isTokenExpiredMock.mockReturnValue(false);
|
||||
locationStub = { href: 'http://localhost/', pathname: '/' };
|
||||
vi.stubGlobal('location', locationStub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('adds Authorization header when access token is available', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
localStorage.setItem('accessToken', 'access-token');
|
||||
|
||||
const { fetchWithAuth } = await import('@/lib/utils/api');
|
||||
await fetchWithAuth('/api/test');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer access-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the access token and retries after a 401 response', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url === '/api/auth/refresh') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ accessToken: 'new-token' }),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: url !== '/api/test' || fetchMock.mock.calls.length > 1,
|
||||
status: url === '/api/test' && fetchMock.mock.calls.length === 1 ? 401 : 200,
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
localStorage.setItem('accessToken', 'old-token');
|
||||
localStorage.setItem('refreshToken', 'refresh-token');
|
||||
|
||||
const { fetchWithAuth } = await import('@/lib/utils/api');
|
||||
await fetchWithAuth('/api/test');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer new-token',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(localStorage.getItem('accessToken')).toBe('new-token');
|
||||
});
|
||||
|
||||
it('logs out when refresh fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 401, ok: false });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
localStorage.setItem('accessToken', 'old-token');
|
||||
localStorage.setItem('refreshToken', 'expired-refresh');
|
||||
localStorage.setItem('user', JSON.stringify({ id: 'user-1' }));
|
||||
|
||||
isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired'));
|
||||
|
||||
locationStub.pathname = '/requests';
|
||||
locationStub.href = 'http://localhost/requests';
|
||||
|
||||
const { fetchWithAuth } = await import('@/lib/utils/api');
|
||||
await fetchWithAuth('/api/test');
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
expect(localStorage.getItem('refreshToken')).toBeNull();
|
||||
expect(localStorage.getItem('user')).toBeNull();
|
||||
expect(window.location.href).toContain('/login?redirect=%2Frequests');
|
||||
});
|
||||
});
|
||||
@@ -316,7 +316,9 @@ describe('getValidVariables', () => {
|
||||
expect(variables).toContain('narrator');
|
||||
expect(variables).toContain('asin');
|
||||
expect(variables).toContain('year');
|
||||
expect(variables).toHaveLength(5);
|
||||
expect(variables).toContain('series');
|
||||
expect(variables).toContain('seriesPart');
|
||||
expect(variables).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('should return a new array each time (not mutate original)', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user