diff --git a/dockerfile.unified b/dockerfile.unified index 2542266..cb17f59 100644 --- a/dockerfile.unified +++ b/dockerfile.unified @@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \ supervisor \ curl \ openssl \ - ffmpeg \ locales \ gosu \ + xz-utils \ && sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \ && locale-gen en_US.UTF-8 \ && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \ && rm -rf /var/lib/apt/lists/* +# Install static ffmpeg (no transitive dependencies like imagemagick) +ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz +RUN cd /tmp && tar xf ffmpeg.tar.xz && \ + cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \ + rm -rf /tmp/ffmpeg* + +# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed) +RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \ + 'libmagickcore*' 'libmagickwand*' && \ + apt-get autoremove -y --purge && \ + rm -rf /var/lib/apt/lists/* + ENV LANG=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8 diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index 7416a4d..f84f093 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -66,7 +66,7 @@ export function RequestActionsDropdown({ const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status); const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload; - const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); + const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status); const canDelete = true; // Admins can always delete // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index eeee7bb..8c99361 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -97,9 +97,27 @@ export async function POST(request: NextRequest) { }); } +// Status groups for server-side filtering and count aggregation +const STATUS_GROUPS: Record = { + active: ['pending', 'searching', 'downloading', 'processing'], + waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'], + completed: ['available', 'downloaded'], + failed: ['failed'], + cancelled: ['cancelled', 'denied'], +}; + /** - * GET /api/requests?status=pending&limit=50 - * Get user's audiobook requests (or all requests for admins) + * GET /api/requests + * Get user's audiobook requests with cursor-based pagination and accurate counts. + * + * Query params: + * status - filter group: 'active'|'waiting'|'completed'|'failed'|'cancelled'|specific status + * cursor - request ID for cursor-based pagination (exclusive start) + * take - page size (default 20, max 100) + * myOnly - 'true' to restrict to current user even for admins + * type - 'audiobook'|'ebook' + * + * Response: { requests, nextCursor, counts: { all, active, waiting, completed, failed, cancelled } } */ export async function GET(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { @@ -112,61 +130,102 @@ export async function GET(request: NextRequest) { } const searchParams = req.nextUrl.searchParams; - const status = searchParams.get('status'); - const limit = parseInt(searchParams.get('limit') || '50', 10); + const statusParam = searchParams.get('status'); + const cursor = searchParams.get('cursor'); + const take = Math.min(parseInt(searchParams.get('take') || '20', 10), 100); + // Legacy support: honour `limit` if `take` not supplied + const limit = searchParams.has('take') + ? take + : Math.min(parseInt(searchParams.get('limit') || '20', 10), 100); const myOnly = searchParams.get('myOnly') === 'true'; - const type = searchParams.get('type'); // 'audiobook', 'ebook', or null for all + const type = searchParams.get('type'); const isAdmin = req.user.role === 'admin'; - // Build query - // If myOnly=true, always filter by current user (even for admins) - // Otherwise, admins see all requests, users see only their own - const where: any = myOnly || !isAdmin ? { userId: req.user.id } : {}; - if (status) { - where.status = status; - } - // Filter by type if specified (otherwise returns all types) - if (type && ['audiobook', 'ebook'].includes(type)) { - where.type = type; - } - // Only show active (non-deleted) requests - where.deletedAt = null; + // Base ownership filter + const baseWhere: any = myOnly || !isAdmin ? { userId: req.user.id } : {}; + baseWhere.deletedAt = null; + if (type && ['audiobook', 'ebook'].includes(type)) { + baseWhere.type = type; + } + + // Resolve status filter + const statusFilter: any = {}; + if (statusParam) { + const group = STATUS_GROUPS[statusParam]; + if (group) { + statusFilter.status = { in: group }; + } else { + // Treat as a specific status literal + statusFilter.status = statusParam; + } + } + + const where = { ...baseWhere, ...statusFilter }; + + // ── Paginated request fetch ────────────────────────────────────────── const requests = await prisma.request.findMany({ where, include: { audiobook: true, user: { - select: { - id: true, - plexUsername: true, - }, + select: { id: true, plexUsername: true }, }, }, orderBy: { createdAt: 'desc' }, - take: limit, + take: limit + 1, // fetch one extra to determine if there's a next page + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), }); - const enriched = requests.map(r => { + const hasNextPage = requests.length > limit; + const page = hasNextPage ? requests.slice(0, limit) : requests; + const nextCursor = hasNextPage ? page[page.length - 1].id : null; + + const enriched = page.map(r => { const isCompleted = COMPLETED_STATUSES.includes(r.status as typeof COMPLETED_STATUSES[number]); const downloadAvailable = isCompleted && !!r.audiobook?.filePath; - // Strip server-side absolute path from client response const audiobook = r.audiobook ? { ...r.audiobook, filePath: undefined } : r.audiobook; return { ...r, audiobook, downloadAvailable }; }); + // ── Accurate counts per group (always scoped to ownership/type filter) ── + const countWhere = { ...baseWhere }; + + const [ + totalAll, + totalActive, + totalWaiting, + totalCompleted, + totalFailed, + totalCancelled, + ] = await Promise.all([ + prisma.request.count({ where: countWhere }), + prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.active } } }), + prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.waiting } } }), + prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.completed } } }), + prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.failed } } }), + prisma.request.count({ where: { ...countWhere, status: { in: STATUS_GROUPS.cancelled } } }), + ]); + return NextResponse.json({ success: true, requests: enriched, + nextCursor, + counts: { + all: totalAll, + active: totalActive, + waiting: totalWaiting, + completed: totalCompleted, + failed: totalFailed, + cancelled: totalCancelled, + }, + // Legacy field for callers that still read `count` count: enriched.length, }); } catch (error) { logger.error('Failed to get requests', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( - { - error: 'FetchError', - message: 'Failed to fetch requests', - }, + { error: 'FetchError', message: 'Failed to fetch requests' }, { status: 500 } ); } diff --git a/src/app/globals.css b/src/app/globals.css index 9b7e2cb..3db6d64 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -197,6 +197,23 @@ body { animation: toast-slide-in 0.3s ease-out; } +/* Requests page list entry animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* Confirmation Dialog */ @keyframes dialog-backdrop-in { from { opacity: 0; } diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index 0841f78..6002988 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -1,221 +1,405 @@ /** - * Component: Requests Page + * Component: My Requests Page * Documentation: documentation/frontend/components.md */ 'use client'; -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Header } from '@/components/layout/Header'; import { RequestCard } from '@/components/requests/RequestCard'; -import { useRequests } from '@/lib/hooks/useRequests'; +import { useMyRequests, RequestFilterGroup, RequestCounts } from '@/lib/hooks/useRequests'; import { useAuth } from '@/contexts/AuthContext'; -import { usePreferences } from '@/contexts/PreferencesContext'; import { cn } from '@/lib/utils/cn'; -type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled'; +// ── Tab configuration ──────────────────────────────────────────────────────── + +interface TabOption { + value: RequestFilterGroup; + label: string; + countKey: keyof RequestCounts; +} + +const TABS: TabOption[] = [ + { value: 'all', label: 'All', countKey: 'all' }, + { value: 'active', label: 'Active', countKey: 'active' }, + { value: 'waiting', label: 'Waiting', countKey: 'waiting' }, + { value: 'completed', label: 'Completed', countKey: 'completed' }, + { value: 'failed', label: 'Failed', countKey: 'failed' }, + { value: 'cancelled', label: 'Cancelled', countKey: 'cancelled' }, +]; + +// ── Count badge ────────────────────────────────────────────────────────────── + +function CountBadge({ count, active }: { count: number; active: boolean }) { + if (count === 0) return null; + return ( + + {count > 999 ? '999+' : count} + + ); +} + +// ── Skeleton card ──────────────────────────────────────────────────────────── + +function SkeletonCard() { + return ( +
+
+ {/* Cover placeholder */} +
+ {/* Content placeholder */} +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +// ── Empty state ────────────────────────────────────────────────────────────── + +function EmptyState({ filter }: { filter: RequestFilterGroup }) { + const isAll = filter === 'all'; + return ( +
+
+ + + +
+
+

+ {isAll ? 'No requests yet' : `No ${filter} requests`} +

+

+ {isAll + ? 'Start by searching for audiobooks and requesting them' + : `You don't have any ${filter} requests right now`} +

+
+ {isAll && ( + + + + + Browse Audiobooks + + )} +
+ ); +} + +// ── Load More button ───────────────────────────────────────────────────────── + +function LoadMoreButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) { + return ( +
+ +
+ ); +} + +// ── Live indicator ─────────────────────────────────────────────────────────── + +function LiveIndicator({ hasActive }: { hasActive: boolean }) { + if (!hasActive) return null; + return ( +
+ + + + + Live +
+ ); +} + +// ── Tab bar ────────────────────────────────────────────────────────────────── + +interface TabBarProps { + filter: RequestFilterGroup; + counts: RequestCounts; + countsLoaded: boolean; + onChange: (f: RequestFilterGroup) => void; +} + +function TabBar({ filter, counts, countsLoaded, onChange }: TabBarProps) { + const scrollRef = useRef(null); + + // Scroll active tab into view on mount/change + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + const active = container.querySelector('[data-active="true"]') as HTMLElement | null; + if (active) { + const { offsetLeft, offsetWidth } = active; + const { scrollLeft, clientWidth } = container; + if (offsetLeft < scrollLeft || offsetLeft + offsetWidth > scrollLeft + clientWidth) { + container.scrollTo({ left: offsetLeft - 16, behavior: 'smooth' }); + } + } + }, [filter]); + + return ( +
+ {/* Left fade */} +
+ {/* Right fade */} +
+ +
+ {TABS.map((tab) => { + const isActive = filter === tab.value; + const count = counts[tab.countKey]; + // Hide tabs with 0 count unless it's 'all' or currently active + if (!isActive && tab.value !== 'all' && countsLoaded && count === 0) return null; + return ( + + ); + })} +
+
+ ); +} + +// ── Showing count bar ──────────────────────────────────────────────────────── + +function ShowingBar({ showing, total, hasActive }: { showing: number; total: number; hasActive: boolean }) { + if (showing === 0) return null; + return ( +
+ + Showing {showing} + {' of '} + {total} + {total === 1 ? ' request' : ' requests'} + + +
+ ); +} + +// ── Main page ──────────────────────────────────────────────────────────────── export default function RequestsPage() { const { user } = useAuth(); - const { squareCovers } = usePreferences(); - const [filter, setFilter] = useState('all'); + const [filter, setFilter] = useState('all'); - // Always fetch only the current user's requests (even for admins) - // This ensures "My Requests" truly shows only the user's own requests - // Admins can see all requests in the admin panel - const { requests, isLoading } = useRequests(undefined, 50, true); + const { + requests, + counts, + hasMore, + isLoading, + isLoadingMore, + isEmpty, + loadMore, + } = useMyRequests(filter); - // Filter requests client-side based on selected filter - const filteredRequests = filter === 'all' - ? requests - : filter === 'active' - ? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)) - : filter === 'waiting' - ? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)) - : filter === 'completed' - ? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)) - : requests.filter((r: any) => r.status === filter); + const countsLoaded = !isLoading || requests.length > 0; + const totalForFilter = counts[filter === 'all' ? 'all' : filter as keyof RequestCounts] ?? 0; + const hasActiveRequests = requests.some(r => + ['pending', 'awaiting_search', 'awaiting_approval', 'searching', 'downloading', 'processing', 'awaiting_import'].includes(r.status) + ); - const filterOptions: { value: FilterStatus; label: string }[] = [ - { value: 'all', label: 'All' }, - { value: 'active', label: 'Active' }, - { value: 'waiting', label: 'Waiting' }, - { value: 'completed', label: 'Completed' }, - { value: 'failed', label: 'Failed' }, - { value: 'cancelled', label: 'Cancelled' }, - ]; + const handleFilterChange = (f: RequestFilterGroup) => { + setFilter(f); + }; - // Redirect to login if not authenticated + // ── Unauthenticated ──────────────────────────────────────────────────────── if (!user) { return (
-
-
- - - -

- Authentication Required -

-

- Please log in to view your audiobook requests -

+
+
+
+ + + +
+
+

Authentication Required

+

Please log in to view your audiobook requests

+
); } + // ── Authenticated ────────────────────────────────────────────────────────── return (
-
- {/* Page Header */} -
-

+
+ + {/* Page header */} +
+

My Requests

-

+

Track the status of your audiobook requests in real-time

- {/* Filter Tabs */} -
-
- {filterOptions.map((option) => ( - - ))} -
+ {/* Tab bar */} +
+
- {/* Loading State */} + {/* Showing bar */} + {!isLoading && requests.length > 0 && ( +
+ +
+ )} + + {/* Loading state — skeleton cards */} {isLoading && ( -
- {[1, 2, 3].map((i) => ( +
+ {Array.from({ length: 4 }).map((_, i) => (
-
-
-
-
-
-
-
-
+
))}
)} - {/* Requests List */} - {!isLoading && filteredRequests.length > 0 && ( -
- {filteredRequests.map((request: any) => ( - + {/* Request list */} + {!isLoading && requests.length > 0 && ( +
+ {requests.map((request, i) => ( +
+ +
))}
)} - {/* Empty State */} - {!isLoading && filteredRequests.length === 0 && ( -
- - - -
-

- {filter === 'all' ? 'No requests yet' : `No ${filter} requests`} -

-

- {filter === 'all' - ? 'Start by searching for audiobooks and requesting them' - : `You don't have any ${filter} requests at the moment` - } -

-
- {filter === 'all' && ( - - )} + {/* Empty state */} + {!isLoading && isEmpty && ( + + )} + + {/* Load more */} + {!isLoading && hasMore && ( +
+
)} - {/* Auto-refresh indicator */} - {!isLoading && filteredRequests.length > 0 && ( -
-
-
- Auto-refreshing every 5 seconds -
+ {/* Load more skeleton (when fetching additional pages) */} + {isLoadingMore && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))}
)} +

); diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index 1c0784e..d8b1e1c 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -50,7 +50,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const isEbook = requestType === 'ebook'; const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]); - const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); + const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status); const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isFailed = request.status === 'failed'; diff --git a/src/lib/hooks/useRequests.ts b/src/lib/hooks/useRequests.ts index b03f31a..74c87b3 100644 --- a/src/lib/hooks/useRequests.ts +++ b/src/lib/hooks/useRequests.ts @@ -5,8 +5,9 @@ 'use client'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import useSWR, { mutate } from 'swr'; +import useSWRInfinite from 'swr/infinite'; import { useAuth } from '@/contexts/AuthContext'; import { fetchWithAuth } from '@/lib/utils/api'; import { Audiobook } from './useAudiobooks'; @@ -59,6 +60,95 @@ export function useRequests(status?: string, limit: number = 50, myOnly: boolean }; } +// ── Active statuses that warrant live polling ──────────────────────────────── +const ACTIVE_STATUSES = new Set([ + 'pending', 'awaiting_search', 'awaiting_approval', + 'searching', 'downloading', 'processing', 'awaiting_import', +]); + +export type RequestFilterGroup = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled'; + +export interface RequestCounts { + all: number; + active: number; + waiting: number; + completed: number; + failed: number; + cancelled: number; +} + +export interface RequestPage { + requests: Request[]; + nextCursor: string | null; + counts: RequestCounts; +} + +const PAGE_SIZE = 20; + +/** + * Paginated hook for "My Requests" page. + * Uses SWRInfinite for cursor-based pagination. + * Polls only when active requests are present. + */ +export function useMyRequests(filter: RequestFilterGroup) { + const { accessToken } = useAuth(); + + const getKey = (pageIndex: number, previousPage: RequestPage | null): string | null => { + if (!accessToken) return null; + if (previousPage && !previousPage.nextCursor) return null; // reached end + + const params = new URLSearchParams(); + params.set('myOnly', 'true'); + params.set('take', String(PAGE_SIZE)); + if (filter !== 'all') params.set('status', filter); + if (pageIndex > 0 && previousPage?.nextCursor) { + params.set('cursor', previousPage.nextCursor); + } + return `/api/requests?${params.toString()}`; + }; + + const { data, error, isLoading, isValidating, size, setSize, mutate: revalidate } = + useSWRInfinite(getKey, fetcher, { + revalidateFirstPage: true, + revalidateOnFocus: false, + // Smart polling: refresh every 5s only when active requests exist + refreshInterval: (data) => { + if (!data) return 5000; + const hasActive = data.some(page => + page.requests.some(r => ACTIVE_STATUSES.has(r.status)) + ); + return hasActive ? 5000 : 0; + }, + }); + + const allRequests = useMemo( + () => data?.flatMap(page => page.requests) ?? [], + [data] + ); + + // Counts come from the first page (always the authoritative totals) + const counts: RequestCounts = data?.[0]?.counts ?? { + all: 0, active: 0, waiting: 0, completed: 0, failed: 0, cancelled: 0, + }; + + const hasMore = data ? !!data[data.length - 1]?.nextCursor : false; + const isLoadingMore = isValidating && size > 1 && !data?.[size - 1]; + const isEmpty = !isLoading && allRequests.length === 0; + + const loadMore = () => setSize(s => s + 1); + + return { + requests: allRequests, + counts, + hasMore, + isLoading, + isLoadingMore, + isEmpty, + loadMore, + revalidate, + }; +} + export function useRequest(requestId: string) { const { accessToken } = useAuth(); diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts index fcb6331..9d6e4ad 100644 --- a/src/lib/processors/plex-recently-added.processor.ts +++ b/src/lib/processors/plex-recently-added.processor.ts @@ -254,7 +254,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa const matchableRequests = await prisma.request.findMany({ where: { type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available') - status: { notIn: ['available', 'cancelled'] }, + status: { notIn: ['available', 'cancelled', 'denied'] }, deletedAt: null, }, include: { diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts index ca062d4..a442665 100644 --- a/src/lib/processors/scan-plex.processor.ts +++ b/src/lib/processors/scan-plex.processor.ts @@ -439,7 +439,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise { const matchableRequests = await prisma.request.findMany({ where: { type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available') - status: { notIn: ['available', 'cancelled'] }, + status: { notIn: ['available', 'cancelled', 'denied'] }, deletedAt: null, }, include: { diff --git a/tests/api/requests.route.test.ts b/tests/api/requests.route.test.ts index 87590a4..73a60c6 100644 --- a/tests/api/requests.route.test.ts +++ b/tests/api/requests.route.test.ts @@ -202,6 +202,7 @@ describe('Requests API routes', () => { it('filters requests for current user when not admin', async () => { authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5'); prismaMock.request.findMany.mockResolvedValueOnce([]); + prismaMock.request.count.mockResolvedValue(0); const { GET } = await import('@/app/api/requests/route'); const response = await GET({} as any); @@ -212,7 +213,7 @@ describe('Requests API routes', () => { expect(prismaMock.request.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: 'user-1', status: 'pending' }), - take: 5, + take: 6, // limit + 1 for cursor pagination next-page detection }) ); }); diff --git a/tests/app/requests.page.test.tsx b/tests/app/requests.page.test.tsx index e0436d0..0b892a9 100644 --- a/tests/app/requests.page.test.tsx +++ b/tests/app/requests.page.test.tsx @@ -11,10 +11,10 @@ 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()); +const useMyRequestsMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/hooks/useRequests', () => ({ - useRequests: useRequestsMock, + useMyRequests: useMyRequestsMock, })); vi.mock('@/components/layout/Header', () => ({ @@ -41,13 +41,18 @@ describe('RequestsPage', () => { beforeEach(() => { resetMockAuthState(); resetMockRouter(); - useRequestsMock.mockReset(); + useMyRequestsMock.mockReset(); vi.resetModules(); }); + const defaultCounts = { all: 0, active: 0, waiting: 0, completed: 0, failed: 0, cancelled: 0 }; + it('prompts for authentication when no user is available', async () => { setMockAuthState({ user: null }); - useRequestsMock.mockReturnValue({ requests: [], isLoading: false }); + useMyRequestsMock.mockReturnValue({ + requests: [], counts: defaultCounts, hasMore: false, + isLoading: false, isLoadingMore: false, isEmpty: true, loadMore: vi.fn(), + }); const { default: RequestsPage } = await import('@/app/requests/page'); render(); @@ -62,23 +67,35 @@ describe('RequestsPage', () => { isLoading: false, }); - const requests = [ + const allRequests = [ { 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 counts = { all: 4, active: 1, waiting: 1, completed: 1, failed: 1, cancelled: 0 }; + + // The hook is called with the current filter; mock returns different data per filter + useMyRequestsMock.mockImplementation((filter: string) => { + let requests = allRequests; + if (filter === 'active') requests = allRequests.filter(r => r.status === 'pending'); + else if (filter === 'waiting') requests = allRequests.filter(r => r.status === 'awaiting_search'); + return { + requests, counts, hasMore: false, + isLoading: false, isLoadingMore: false, isEmpty: requests.length === 0, loadMore: vi.fn(), + }; + }); const { default: RequestsPage } = await import('@/app/requests/page'); render(); - const activeTab = screen.getByRole('button', { name: /Active/i }); - const waitingTab = screen.getByRole('button', { name: /Waiting/i }); + // Counts now render as badge numbers inside tabs, not "(1)" format + const activeTab = screen.getByRole('tab', { name: /Active/i }); + const waitingTab = screen.getByRole('tab', { name: /Waiting/i }); - expect(activeTab).toHaveTextContent('(1)'); - expect(waitingTab).toHaveTextContent('(1)'); + expect(activeTab).toHaveTextContent('1'); + expect(waitingTab).toHaveTextContent('1'); fireEvent.click(activeTab);