mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-07-14 16:51:10 +00:00
Advance existing requests from Interactive Search
Pass requestId and requestedByUserId through request list and card components and into AudiobookDetailsModal. Add ADVANCEABLE_FROM_INTERACTIVE_SEARCH constant and logic so the Interactive Search modal routes to /api/requests/[id]/select-torrent for own/admin advanceable requests instead of creating a new request. Fix download-token usage to use the in-flight request id, extend Audiobook type with requestedByUserId, and add tests covering advance-vs-create routing and select-torrent behavior.
This commit is contained in:
@@ -198,6 +198,8 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
|||||||
// View Details modal state
|
// View Details modal state
|
||||||
const [viewDetailsAsin, setViewDetailsAsin] = useState<string | null>(null);
|
const [viewDetailsAsin, setViewDetailsAsin] = useState<string | null>(null);
|
||||||
const [viewDetailsStatus, setViewDetailsStatus] = useState<string | null>(null);
|
const [viewDetailsStatus, setViewDetailsStatus] = useState<string | null>(null);
|
||||||
|
const [viewDetailsRequestId, setViewDetailsRequestId] = useState<string | null>(null);
|
||||||
|
const [viewDetailsUserId, setViewDetailsUserId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Build API URL with current local filters
|
// Build API URL with current local filters
|
||||||
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
|
||||||
@@ -328,9 +330,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
|||||||
const hasActiveFilters = debouncedSearch || status !== 'all' || userId;
|
const hasActiveFilters = debouncedSearch || status !== 'all' || userId;
|
||||||
|
|
||||||
// Action handlers
|
// Action handlers
|
||||||
const handleViewDetails = (asin: string, requestStatus?: string) => {
|
const handleViewDetails = (
|
||||||
|
asin: string,
|
||||||
|
requestStatus?: string,
|
||||||
|
requestId?: string,
|
||||||
|
userId?: string,
|
||||||
|
) => {
|
||||||
setViewDetailsAsin(asin);
|
setViewDetailsAsin(asin);
|
||||||
setViewDetailsStatus(requestStatus || null);
|
setViewDetailsStatus(requestStatus || null);
|
||||||
|
setViewDetailsRequestId(requestId || null);
|
||||||
|
setViewDetailsUserId(userId || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (requestId: string, title: string) => {
|
const handleDeleteClick = (requestId: string, title: string) => {
|
||||||
@@ -728,7 +737,7 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
|||||||
onManualSearch={handleManualSearch}
|
onManualSearch={handleManualSearch}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onRetryDownload={handleRetryDownload}
|
onRetryDownload={handleRetryDownload}
|
||||||
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
|
onViewDetails={(asin) => handleViewDetails(asin, request.status, request.requestId, request.userId)}
|
||||||
onFetchEbook={handleFetchEbook}
|
onFetchEbook={handleFetchEbook}
|
||||||
onSearchTermsUpdated={() => mutate(apiUrl)}
|
onSearchTermsUpdated={() => mutate(apiUrl)}
|
||||||
ebookSidecarEnabled={ebookSidecarEnabled}
|
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||||
@@ -884,9 +893,13 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setViewDetailsAsin(null);
|
setViewDetailsAsin(null);
|
||||||
setViewDetailsStatus(null);
|
setViewDetailsStatus(null);
|
||||||
|
setViewDetailsRequestId(null);
|
||||||
|
setViewDetailsUserId(null);
|
||||||
}}
|
}}
|
||||||
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
|
||||||
requestStatus={viewDetailsStatus}
|
requestStatus={viewDetailsStatus}
|
||||||
|
requestId={viewDetailsRequestId}
|
||||||
|
requestedByUserId={viewDetailsUserId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -427,6 +427,8 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
|
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
|
||||||
requestStatus="awaiting_approval"
|
requestStatus="awaiting_approval"
|
||||||
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
|
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
|
||||||
|
requestId={detailsRequestId}
|
||||||
|
requestedByUserId={detailsRequest?.user.id ?? null}
|
||||||
adminActions={
|
adminActions={
|
||||||
<ApprovalActionButtons
|
<ApprovalActionButtons
|
||||||
isLoading={loadingStates[detailsRequestId] || false}
|
isLoading={loadingStates[detailsRequestId] || false}
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ export function AudiobookCard({
|
|||||||
requestStatus={displayAudiobook.requestStatus}
|
requestStatus={displayAudiobook.requestStatus}
|
||||||
isAvailable={audiobook.isAvailable}
|
isAvailable={audiobook.isAvailable}
|
||||||
requestedByUsername={audiobook.requestedByUsername}
|
requestedByUsername={audiobook.requestedByUsername}
|
||||||
|
requestId={audiobook.requestId}
|
||||||
|
requestedByUserId={audiobook.requestedByUserId}
|
||||||
hasReportedIssue={audiobook.hasReportedIssue}
|
hasReportedIssue={audiobook.hasReportedIssue}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
|||||||
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
|
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
|
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
|
||||||
|
import { ADVANCEABLE_FROM_INTERACTIVE_SEARCH } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
interface AudiobookDetailsModalProps {
|
interface AudiobookDetailsModalProps {
|
||||||
asin: string;
|
asin: string;
|
||||||
@@ -35,6 +36,10 @@ interface AudiobookDetailsModalProps {
|
|||||||
requestStatus?: string | null;
|
requestStatus?: string | null;
|
||||||
isAvailable?: boolean;
|
isAvailable?: boolean;
|
||||||
requestedByUsername?: string | null;
|
requestedByUsername?: string | null;
|
||||||
|
/** In-flight request id for this audiobook (own or other user's). When set together with an advanceable status and own/admin viewer, Interactive Search → Download routes through select-torrent instead of creating a new request. */
|
||||||
|
requestId?: string | null;
|
||||||
|
/** Owner of the in-flight request (matcher-populated). Used together with the viewer's id/role to gate advance-vs-create routing. */
|
||||||
|
requestedByUserId?: string | null;
|
||||||
hideRequestActions?: boolean;
|
hideRequestActions?: boolean;
|
||||||
hasReportedIssue?: boolean;
|
hasReportedIssue?: boolean;
|
||||||
aiReason?: string | null;
|
aiReason?: string | null;
|
||||||
@@ -79,6 +84,8 @@ export function AudiobookDetailsModal({
|
|||||||
requestStatus = null,
|
requestStatus = null,
|
||||||
isAvailable = false,
|
isAvailable = false,
|
||||||
requestedByUsername = null,
|
requestedByUsername = null,
|
||||||
|
requestId = null,
|
||||||
|
requestedByUserId = null,
|
||||||
hideRequestActions = false,
|
hideRequestActions = false,
|
||||||
hasReportedIssue = false,
|
hasReportedIssue = false,
|
||||||
aiReason = null,
|
aiReason = null,
|
||||||
@@ -89,7 +96,7 @@ export function AudiobookDetailsModal({
|
|||||||
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
|
||||||
const { createRequest, isLoading: isRequesting } = useCreateRequest();
|
const { createRequest, isLoading: isRequesting } = useCreateRequest();
|
||||||
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
|
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
|
||||||
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
const { downloadAvailable, requestId: downloadRequestId } = useDownloadStatus(isOpen ? asin : null);
|
||||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||||
|
|
||||||
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
|
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
|
||||||
@@ -118,6 +125,15 @@ export function AudiobookDetailsModal({
|
|||||||
const status = getStatusInfo(isAvailable, effectiveStatus, requestedByUsername);
|
const status = getStatusInfo(isAvailable, effectiveStatus, requestedByUsername);
|
||||||
const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
|
const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
|
||||||
|
|
||||||
|
// Advance the existing request via select-torrent (instead of creating a new one)
|
||||||
|
// only when the viewer owns the request, or is admin, AND the status is one we route on.
|
||||||
|
// Outside this predicate the search modal falls back to today's "create new request" path.
|
||||||
|
const shouldAdvance = !!requestId
|
||||||
|
&& !!user
|
||||||
|
&& (requestedByUserId === user.id || user.role === 'admin')
|
||||||
|
&& (ADVANCEABLE_FROM_INTERACTIVE_SEARCH as readonly string[]).includes(effectiveStatus ?? '');
|
||||||
|
const advanceRequestId = shouldAdvance ? requestId ?? undefined : undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -192,10 +208,10 @@ export function AudiobookDetailsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
if (!requestId) return;
|
if (!downloadRequestId) return;
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuth(`/api/requests/${requestId}/download-token`, { method: 'POST' });
|
const res = await fetchWithAuth(`/api/requests/${downloadRequestId}/download-token`, { method: 'POST' });
|
||||||
if (!res.ok) throw new Error('Failed to get download link');
|
if (!res.ok) throw new Error('Failed to get download link');
|
||||||
const { downloadUrl } = await res.json();
|
const { downloadUrl } = await res.json();
|
||||||
window.location.href = downloadUrl;
|
window.location.href = downloadUrl;
|
||||||
@@ -576,7 +592,7 @@ export function AudiobookDetailsModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Download Link - subtle utility, visible from any context */}
|
{/* Download Link - subtle utility, visible from any context */}
|
||||||
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
|
{isAvailable && downloadAvailable && downloadRequestId && user?.permissions?.download !== false && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 dark:text-gray-400">Download</p>
|
<p className="text-gray-500 dark:text-gray-400">Download</p>
|
||||||
<button
|
<button
|
||||||
@@ -807,6 +823,7 @@ export function AudiobookDetailsModal({
|
|||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
onRequestSuccess?.();
|
onRequestSuccess?.();
|
||||||
}}
|
}}
|
||||||
|
requestId={advanceRequestId}
|
||||||
audiobook={{
|
audiobook={{
|
||||||
title: audiobook.title,
|
title: audiobook.title,
|
||||||
author: audiobook.author,
|
author: audiobook.author,
|
||||||
|
|||||||
@@ -15,3 +15,15 @@ export const CANCELLABLE_STATUSES = [
|
|||||||
'awaiting_approval',
|
'awaiting_approval',
|
||||||
'awaiting_release',
|
'awaiting_release',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses where an own (or admin-acted) request can be advanced via Interactive
|
||||||
|
* Search → Download by routing to /api/requests/[id]/select-torrent.
|
||||||
|
* Outside this set, the modal falls back to today's create-new-request path.
|
||||||
|
*/
|
||||||
|
export const ADVANCEABLE_FROM_INTERACTIVE_SEARCH = [
|
||||||
|
'pending',
|
||||||
|
'failed',
|
||||||
|
'awaiting_search',
|
||||||
|
'awaiting_release',
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface Audiobook {
|
|||||||
isRequested?: boolean; // Set if ANY user has requested this audiobook
|
isRequested?: boolean; // Set if ANY user has requested this audiobook
|
||||||
requestStatus?: string | null; // Status of request (if any)
|
requestStatus?: string | null; // Status of request (if any)
|
||||||
requestId?: string | null; // ID of request (if any)
|
requestId?: string | null; // ID of request (if any)
|
||||||
|
requestedByUserId?: string | null; // User id who requested (populated by audiobook-matcher; used to gate own-request interactive-search routing)
|
||||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||||
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
||||||
|
|||||||
@@ -409,6 +409,69 @@ describe('Request action routes', () => {
|
|||||||
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// awaiting_release happy-path coverage — Interactive Search → Download from
|
||||||
|
// AudiobookDetailsModal now routes through select-torrent for own/admin
|
||||||
|
// advanceable requests; the release-date gate is intentionally bypassed.
|
||||||
|
it('selects a torrent and queues download from awaiting_release (admin)', async () => {
|
||||||
|
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||||
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'req-ar-admin',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: 'awaiting_release',
|
||||||
|
audiobook: { id: 'ab-ar-1', title: 'Title', author: 'Author' },
|
||||||
|
} as any);
|
||||||
|
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'user-1',
|
||||||
|
role: 'admin',
|
||||||
|
autoApproveRequests: null,
|
||||||
|
plexUsername: 'adminuser',
|
||||||
|
} as any);
|
||||||
|
prismaMock.request.update.mockResolvedValueOnce({
|
||||||
|
id: 'req-ar-admin',
|
||||||
|
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-ar-admin' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(payload.success).toBe(true);
|
||||||
|
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||||
|
expect(jobQueueMock.addNotificationJob).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores selected torrent on awaiting_release when approval is required', async () => {
|
||||||
|
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||||
|
configState.values.set('auto_approve_requests', 'false');
|
||||||
|
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||||
|
id: 'req-ar-user',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: 'awaiting_release',
|
||||||
|
audiobook: { id: 'ab-ar-2', 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-ar-user',
|
||||||
|
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-ar-user' }) });
|
||||||
|
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('returns error when ebook sidecar is disabled', async () => {
|
it('returns error when ebook sidecar is disabled', async () => {
|
||||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,12 @@ vi.mock('@/lib/hooks/useRequests', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||||
InteractiveTorrentSearchModal: ({ isOpen }: { isOpen: boolean }) => (
|
InteractiveTorrentSearchModal: ({ isOpen, requestId }: { isOpen: boolean; requestId?: string }) => (
|
||||||
<div data-testid="interactive-modal" data-open={String(isOpen)} />
|
<div
|
||||||
|
data-testid="interactive-modal"
|
||||||
|
data-open={String(isOpen)}
|
||||||
|
data-request-id={requestId ?? ''}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -327,4 +331,88 @@ describe('AudiobookDetailsModal', () => {
|
|||||||
expect(screen.getByTitle('Interactive Search')).toBeInTheDocument();
|
expect(screen.getByTitle('Interactive Search')).toBeInTheDocument();
|
||||||
expect(screen.getByTitle('Manual Import')).toBeInTheDocument();
|
expect(screen.getByTitle('Manual Import')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Interactive Search routing (advance vs. create)', () => {
|
||||||
|
const openInteractiveAndReadForwardedRequestId = async (props: {
|
||||||
|
user: { id: string; username: string; role?: string };
|
||||||
|
requestStatus?: string | null;
|
||||||
|
requestId?: string | null;
|
||||||
|
requestedByUserId?: string | null;
|
||||||
|
}) => {
|
||||||
|
useAuthMock.mockReturnValue({ user: props.user });
|
||||||
|
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AudiobookDetailsModal
|
||||||
|
asin="ASIN123"
|
||||||
|
isOpen={true}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
requestStatus={props.requestStatus ?? null}
|
||||||
|
requestId={props.requestId ?? null}
|
||||||
|
requestedByUserId={props.requestedByUserId ?? null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {});
|
||||||
|
fireEvent.click(screen.getByTitle('Interactive Search'));
|
||||||
|
const modal = screen.getByTestId('interactive-modal');
|
||||||
|
return modal.getAttribute('data-request-id') ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(['pending', 'failed', 'awaiting_search', 'awaiting_release'])(
|
||||||
|
'forwards requestId when own user has an advanceable %s request',
|
||||||
|
async (status) => {
|
||||||
|
const forwarded = await openInteractiveAndReadForwardedRequestId({
|
||||||
|
user: { id: 'user-1', username: 'u' },
|
||||||
|
requestStatus: status,
|
||||||
|
requestId: 'req-advance',
|
||||||
|
requestedByUserId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(forwarded).toBe('req-advance');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['awaiting_approval', 'searching', 'downloading', 'processing', 'denied'])(
|
||||||
|
'does NOT forward requestId when own status is %s (blocked / non-advanceable)',
|
||||||
|
async (status) => {
|
||||||
|
const forwarded = await openInteractiveAndReadForwardedRequestId({
|
||||||
|
user: { id: 'user-1', username: 'u' },
|
||||||
|
requestStatus: status,
|
||||||
|
requestId: 'req-x',
|
||||||
|
requestedByUserId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(forwarded).toBe('');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does NOT forward requestId for a non-admin viewing another user\'s awaiting_search request', async () => {
|
||||||
|
const forwarded = await openInteractiveAndReadForwardedRequestId({
|
||||||
|
user: { id: 'user-2', username: 'other' },
|
||||||
|
requestStatus: 'awaiting_search',
|
||||||
|
requestId: 'req-from-user-1',
|
||||||
|
requestedByUserId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(forwarded).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards requestId for an admin viewing another user\'s awaiting_search request', async () => {
|
||||||
|
const forwarded = await openInteractiveAndReadForwardedRequestId({
|
||||||
|
user: { id: 'admin-1', username: 'admin', role: 'admin' },
|
||||||
|
requestStatus: 'awaiting_search',
|
||||||
|
requestId: 'req-from-user-1',
|
||||||
|
requestedByUserId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(forwarded).toBe('req-from-user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT forward requestId when caller omits requestId entirely', async () => {
|
||||||
|
const forwarded = await openInteractiveAndReadForwardedRequestId({
|
||||||
|
user: { id: 'user-1', username: 'u' },
|
||||||
|
requestStatus: 'awaiting_search',
|
||||||
|
requestId: null,
|
||||||
|
requestedByUserId: 'user-1',
|
||||||
|
});
|
||||||
|
expect(forwarded).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ describe('InteractiveTorrentSearchModal', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult);
|
expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult);
|
||||||
});
|
});
|
||||||
|
// When requestId is set, the modal must NOT fall into the create-new-request
|
||||||
|
// branch — that's the routing fix for own-request advanceable states.
|
||||||
|
expect(requestWithTorrentMock).not.toHaveBeenCalled();
|
||||||
expect(onSuccess).toHaveBeenCalled();
|
expect(onSuccess).toHaveBeenCalled();
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user