From a81549768cc31252ad580b569007ba267125ff8e Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 6 Mar 2026 10:41:17 -0500 Subject: [PATCH] Add paginated requests API and My Requests UI Introduce cursor-based pagination and group counts for /api/requests (status groups, nextCursor, counts) and fetch one extra record to detect next page. Add a client-side My Requests experience: useSWRInfinite hook (useMyRequests) with smart polling for active requests, tabbed filters, badges, skeletons, load-more, and animated list entries. Update RequestCard and admin actions to treat awaiting_search as cancellable. Adjust Plex processors to ignore requests with status 'denied' when matching new media. Add static ffmpeg in the Docker image and remove preinstalled ImageMagick to avoid transitive deps. Update tests to account for pagination/take+1 and the new hook/UX behavior. --- dockerfile.unified | 14 +- .../components/RequestActionsDropdown.tsx | 2 +- src/app/api/requests/route.ts | 117 +++- src/app/globals.css | 17 + src/app/requests/page.tsx | 504 ++++++++++++------ src/components/requests/RequestCard.tsx | 2 +- src/lib/hooks/useRequests.ts | 92 +++- .../plex-recently-added.processor.ts | 2 +- src/lib/processors/scan-plex.processor.ts | 2 +- tests/api/requests.route.test.ts | 3 +- tests/app/requests.page.test.tsx | 37 +- 11 files changed, 586 insertions(+), 206 deletions(-) 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);