Fix direct search request flow (#644)

Fixes #643
This commit is contained in:
Alex
2026-02-23 09:27:55 +00:00
committed by GitHub
parent 0d271f1f69
commit c80c88676c
9 changed files with 387 additions and 150 deletions
+4 -1
View File
@@ -495,7 +495,10 @@ def register_request_routes(
if resolved_mode == PolicyMode.REQUEST_BOOK:
requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else ""
if requested_level != "book":
# Direct search results are already concrete releases, so allow release-level
# request payloads even when the policy default is request_book.
allow_direct_release_payload = source == "direct_download" and requested_level == "release"
if requested_level != "book" and not allow_direct_release_payload:
logger.debug(
"Request not created for '%s' by %s: policy requires book-level requests",
request_title,
+58 -14
View File
@@ -2043,6 +2043,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
"""
try:
from shelfmark.metadata_providers import (
BookMetadata,
get_provider,
is_provider_registered,
get_provider_kwargs,
@@ -2072,27 +2073,70 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
if not provider or not book_id:
return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400
if not is_provider_registered(provider):
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
# Direct mode request approvals can open ReleaseModal with provider=direct_download.
# In that flow, treat the direct result as release-search context instead of requiring
# a metadata provider registration.
if provider == "direct_download":
direct_book = backend.get_book_info(book_id)
if not isinstance(direct_book, dict):
return jsonify({"error": "Book not found in direct source"}), 404
# Get book metadata from provider
kwargs = get_provider_kwargs(provider)
prov = get_provider(provider, **kwargs)
book = prov.get_book(book_id)
resolved_title = title_param or str(direct_book.get("title") or "").strip() or "Unknown title"
resolved_author = author_param or str(direct_book.get("author") or "").strip()
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
if not authors and resolved_author:
authors = [resolved_author]
if not book:
return jsonify({"error": "Book not found in metadata provider"}), 404
raw_publish_year = direct_book.get("year")
publish_year = None
if isinstance(raw_publish_year, int):
publish_year = raw_publish_year
elif isinstance(raw_publish_year, str):
normalized_year = raw_publish_year.strip()
if normalized_year.isdigit():
publish_year = int(normalized_year)
# Override title from frontend if available (search results may have better data)
# Note: We intentionally DON'T override authors here - get_book() now returns
# filtered authors (primary authors only, excluding translators/narrators),
# which gives better release search results than the unfiltered search data
if title_param:
book.title = title_param
book = BookMetadata(
provider="direct_download",
provider_id=book_id,
provider_display_name="Direct Download",
title=resolved_title,
search_title=resolved_title,
search_author=resolved_author or None,
authors=authors,
cover_url=direct_book.get("preview"),
description=direct_book.get("description"),
publisher=direct_book.get("publisher"),
publish_year=publish_year,
language=direct_book.get("language"),
source_url=direct_book.get("source_url"),
)
else:
if not is_provider_registered(provider):
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
# Get book metadata from provider
kwargs = get_provider_kwargs(provider)
prov = get_provider(provider, **kwargs)
book = prov.get_book(book_id)
if not book:
return jsonify({"error": "Book not found in metadata provider"}), 404
# Override title from frontend if available (search results may have better data)
# Note: We intentionally DON'T override authors here - get_book() now returns
# filtered authors (primary authors only, excluding translators/narrators),
# which gives better release search results than the unfiltered search data
if title_param:
book.title = title_param
# Determine which release sources to search
if source_filter:
sources_to_search = [source_filter]
elif provider == "direct_download":
# Direct mode has no metadata-provider fanout; keep release browsing focused
# on Direct Download results (same dataset as legacy direct search).
sources_to_search = ["direct_download"]
else:
# Search only enabled sources
sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]]
+2
View File
@@ -1356,6 +1356,8 @@ function App() {
currentStatus={statusForButtonState}
defaultReleaseSource={config?.default_release_source}
onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries}
defaultShowManualQuery={isBrowseFulfilMode}
isRequestMode={isBrowseFulfilMode}
/>
)}
+153 -129
View File
@@ -370,6 +370,8 @@ interface ReleaseModalProps {
currentStatus: StatusData;
defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download')
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
defaultShowManualQuery?: boolean;
isRequestMode?: boolean;
}
@@ -799,11 +801,14 @@ export const ReleaseModal = ({
currentStatus,
defaultReleaseSource,
onSearchSeries,
defaultShowManualQuery = false,
isRequestMode = false,
}: ReleaseModalProps) => {
// Use audiobook formats when in audiobook mode
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
? supportedAudiobookFormats
: supportedFormats;
const isDirectProviderContext = (book?.provider || '').toLowerCase() === 'direct_download';
const [isClosing, setIsClosing] = useState(false);
const [isRequestingBook, setIsRequestingBook] = useState(false);
@@ -904,6 +909,7 @@ export const ReleaseModal = ({
useEffect(() => {
setDescriptionExpanded(false);
setDescriptionOverflows(false);
setShowHeaderThumb(false);
setReleasesBySource({});
setLoadingBySource({});
setErrorBySource({});
@@ -912,8 +918,11 @@ export const ReleaseModal = ({
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
setIndexerFilter([]);
indexerFilterInitializedRef.current = new Set();
setManualQuery('');
setShowManualQuery(false);
const baseTitle = book?.search_title || book?.title || '';
const baseAuthor = book?.search_author || book?.author || '';
const defaultQuery = `${baseTitle} ${baseAuthor}`.trim();
setManualQuery(defaultShowManualQuery ? defaultQuery : '');
setShowManualQuery(defaultShowManualQuery);
setSearchStatus(null);
lastStatusTimeRef.current = 0;
pendingStatusRef.current = null;
@@ -921,7 +930,7 @@ export const ReleaseModal = ({
clearTimeout(statusTimeoutRef.current);
statusTimeoutRef.current = null;
}
}, [book?.id]);
}, [book?.id, defaultShowManualQuery, book?.search_title, book?.title, book?.search_author, book?.author]);
// Set up WebSocket listener for search status updates
useEffect(() => {
@@ -1032,14 +1041,26 @@ export const ReleaseModal = ({
try {
setSourcesLoading(true);
const sources = await getReleaseSources();
setAvailableSources(sources);
const modalSources = isDirectProviderContext
? sources.filter((source) => source.name === 'direct_download')
: sources;
setAvailableSources(modalSources);
// Filter sources by content type support
const supportedSources = sources.filter(s => {
const supportedSources = modalSources.filter(s => {
const types = s.supported_content_types || ['ebook', 'audiobook'];
return types.includes(contentType);
});
if (isDirectProviderContext) {
if (supportedSources.some((source) => source.name === 'direct_download')) {
setActiveTab('direct_download');
} else {
setActiveTab('');
}
return;
}
// Set active tab: prefer defaultReleaseSource if enabled and supports content type
if (supportedSources.length > 0) {
const enabledSources = supportedSources.filter(s => s.enabled);
@@ -1077,7 +1098,7 @@ export const ReleaseModal = ({
};
fetchSources();
}, [book, defaultReleaseSource, contentType]);
}, [book, defaultReleaseSource, contentType, isDirectProviderContext]);
// Fetch releases when active tab changes (with caching)
// Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch
@@ -1178,8 +1199,9 @@ export const ReleaseModal = ({
// Filter to only enabled sources that support this content type
availableSources.forEach((src) => {
// Skip disabled sources entirely - they won't appear as tabs
if (!src.enabled) {
const allowDisabledDirectTab = isDirectProviderContext && src.name === 'direct_download';
// Skip disabled sources entirely, except direct tab in direct-provider context.
if (!src.enabled && !allowDisabledDirectTab) {
return;
}
@@ -1202,7 +1224,7 @@ export const ReleaseModal = ({
}
return enabledTabs;
}, [availableSources, defaultReleaseSource, contentType]);
}, [availableSources, defaultReleaseSource, contentType, isDirectProviderContext]);
// Update tab indicator position when active tab changes
useEffect(() => {
@@ -1593,140 +1615,142 @@ export const ReleaseModal = ({
{/* Scrollable content */}
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Book summary - scrolls with content */}
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
{book.preview ? (
<img
src={book.preview}
alt="Book cover"
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
/>
) : (
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
No cover
</div>
)}
<div className="flex-1 min-w-0 space-y-2">
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
{book.year && <span>{book.year}</span>}
{displayFields?.starField && (
<span className="flex items-center gap-1.5">
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
<span>{displayFields.starField.value}</span>
{displayFields.ratingsField && (
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
)}
</span>
)}
{displayFields?.usersField && (
<span className="flex items-center gap-1">
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
{displayFields.usersField.value} readers
</span>
)}
{displayFields?.pagesField && (
<span>{displayFields.pagesField.value} pages</span>
)}
</div>
{!isRequestMode && (
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
{book.preview ? (
<img
src={book.preview}
alt="Book cover"
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
/>
) : (
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
No cover
</div>
)}
<div className="flex-1 min-w-0 space-y-2">
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
{book.year && <span>{book.year}</span>}
{displayFields?.starField && (
<span className="flex items-center gap-1.5">
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
<span>{displayFields.starField.value}</span>
{displayFields.ratingsField && (
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
)}
</span>
)}
{displayFields?.usersField && (
<span className="flex items-center gap-1">
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
{displayFields.usersField.value} readers
</span>
)}
{displayFields?.pagesField && (
<span>{displayFields.pagesField.value} pages</span>
)}
</div>
{/* Series info */}
{book.series_name && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>
{book.series_position != null ? (
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
) : (
<>Part of {book.series_name}</>
{/* Series info */}
{book.series_name && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>
{book.series_position != null ? (
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
) : (
<>Part of {book.series_name}</>
)}
</span>
{onSearchSeries && (
<button
type="button"
onClick={() => {
onSearchSeries(book.series_name!);
handleClose();
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
View series
</button>
)}
</span>
{onSearchSeries && (
</div>
)}
{/* Description */}
{book.description && (
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
{book.description}
{descriptionExpanded && descriptionOverflows && (
<>
{' '}
<button
type="button"
onClick={() => setDescriptionExpanded(false)}
className="text-emerald-600 dark:text-emerald-400 hover:underline font-medium inline"
>
Show less
</button>
</>
)}
</p>
{!descriptionExpanded && descriptionOverflows && (
<button
type="button"
onClick={() => setDescriptionExpanded(true)}
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
>
more
</button>
)}
</div>
)}
{/* Links row */}
<div className="flex flex-wrap items-center gap-3 text-xs">
{(book.isbn_13 || book.isbn_10) && (
<span className="text-gray-500 dark:text-gray-400">
ISBN: {book.isbn_13 || book.isbn_10}
</span>
)}
{book.source_url && (
<a
href={book.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 hover:underline"
>
View on {providerDisplay}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{onRequestBook && (
<button
type="button"
onClick={() => {
onSearchSeries(book.series_name!);
handleClose();
void handleRequestBook();
}}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
disabled={isRequestingBook}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
View series
{isRequestingBook ? 'Adding...' : 'Add to requests'}
</button>
)}
</div>
)}
{/* Description */}
{book.description && (
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
{book.description}
{descriptionExpanded && descriptionOverflows && (
<>
{' '}
<button
type="button"
onClick={() => setDescriptionExpanded(false)}
className="text-emerald-600 dark:text-emerald-400 hover:underline font-medium inline"
>
Show less
</button>
</>
)}
</p>
{!descriptionExpanded && descriptionOverflows && (
<button
type="button"
onClick={() => setDescriptionExpanded(true)}
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
>
more
</button>
)}
</div>
)}
{/* Links row */}
<div className="flex flex-wrap items-center gap-3 text-xs">
{(book.isbn_13 || book.isbn_10) && (
<span className="text-gray-500 dark:text-gray-400">
ISBN: {book.isbn_13 || book.isbn_10}
</span>
)}
{book.source_url && (
<a
href={book.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 hover:underline"
>
View on {providerDisplay}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
{onRequestBook && (
<button
type="button"
onClick={() => {
void handleRequestBook();
}}
disabled={isRequestingBook}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{isRequestingBook ? 'Adding...' : 'Add to requests'}
</button>
)}
</div>
</div>
</div>
)}
{/* Source tabs + filters - sticky within scroll container */}
<div className="sticky top-0 z-10 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
@@ -434,7 +434,7 @@ export const ActivityCard = ({
const provider = toOptionalText(bookData.provider)?.toLowerCase();
const providerId = toOptionalText(bookData.provider_id);
const canBrowseAlternatives = Boolean(provider && providerId && provider !== 'direct_download');
const canBrowseAlternatives = Boolean(provider && providerId);
const handleReviewApprove = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
@@ -45,13 +45,14 @@ describe('requestPayload utilities', () => {
assert.equal(payload.release_data?.source, 'direct_download');
});
it('creates direct request payload at book level for request_book mode', () => {
it('creates direct request payload with attached release for request_book mode', () => {
const payload = buildDirectRequestPayload(baseBook, 'request_book');
assert.equal(payload.context.request_level, 'book');
assert.equal(payload.context.request_level, 'release');
assert.equal(payload.context.source, 'direct_download');
assert.equal(payload.context.content_type, 'ebook');
assert.equal(payload.release_data, null);
assert.ok(payload.release_data);
assert.equal(payload.release_data?.source, 'direct_download');
});
it('builds metadata book + release payload fragments', () => {
+6 -2
View File
@@ -90,14 +90,18 @@ export const buildDirectRequestPayload = (
mode: Extract<RequestPolicyMode, 'request_release' | 'request_book'>
): CreateRequestPayload => {
const bookData = buildDirectBookRequestData(book);
// In direct mode, every result already represents a concrete downloadable release.
// Even when policy defaults resolve to request_book, attach the selected release so
// admins can approve immediately or browse alternatives from the same record.
if (mode === 'request_book') {
return {
book_data: bookData,
release_data: null,
release_data: buildReleaseDataFromDirectBook(book),
context: {
source: 'direct_download',
content_type: 'ebook',
request_level: 'book',
request_level: 'release',
},
};
}
@@ -0,0 +1,121 @@
"""Tests for /api/releases with direct_download provider context."""
from __future__ import annotations
import importlib
from unittest.mock import patch
import pytest
from shelfmark.release_sources import (
ColumnAlign,
ColumnRenderType,
ColumnSchema,
Release,
ReleaseColumnConfig,
)
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
class _FakeDirectSource:
last_search_type = "title_author"
def search(self, book, plan, expand_search=False, content_type="ebook"): # noqa: ANN001
assert book.provider == "direct_download"
assert book.provider_id == "md5-abc"
assert book.title == "The Gun Seller"
assert plan.primary_query
return [
Release(
source="direct_download",
source_id="md5-rel-1",
title="The Gun Seller",
format="epub",
size="2 MB",
)
]
def get_column_config(self):
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="format",
label="Format",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="80px",
),
],
grid_template="minmax(0,2fr) 80px",
)
def test_releases_accepts_direct_download_provider(main_module, client):
fake_direct_source = _FakeDirectSource()
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(
main_module.backend,
"get_book_info",
return_value={
"id": "md5-abc",
"title": "The Gun Seller",
"author": "Iain Banks",
"preview": "https://example.com/cover.jpg",
},
) as mock_get_book_info:
with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source:
with patch(
"shelfmark.release_sources.list_available_sources",
side_effect=AssertionError("list_available_sources should not be called"),
):
resp = client.get(
"/api/releases",
query_string={
"provider": "direct_download",
"book_id": "md5-abc",
},
)
assert resp.status_code == 200
body = resp.get_json()
assert body["sources_searched"] == ["direct_download"]
assert body["book"]["provider"] == "direct_download"
assert body["book"]["provider_id"] == "md5-abc"
assert body["book"]["title"] == "The Gun Seller"
assert body["releases"][0]["source"] == "direct_download"
assert body["releases"][0]["source_id"] == "md5-rel-1"
assert body["search_info"]["direct_download"]["search_type"] == "title_author"
mock_get_book_info.assert_called_once_with("md5-abc")
mock_get_source.assert_called_once_with("direct_download")
def test_releases_direct_provider_returns_404_when_book_missing(main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "get_book_info", return_value=None):
with patch("shelfmark.release_sources.get_source") as mock_get_source:
resp = client.get(
"/api/releases",
query_string={
"provider": "direct_download",
"book_id": "missing-md5",
},
)
assert resp.status_code == 404
assert resp.get_json() == {"error": "Book not found in direct source"}
mock_get_source.assert_not_called()
+38
View File
@@ -541,6 +541,44 @@ class TestRequestRoutes:
assert resp.json["code"] == "policy_requires_request"
assert resp.json["required_mode"] == "request_book"
def test_request_book_policy_allows_direct_release_level_request(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
policy = _policy(default_ebook="request_book")
payload = {
"book_data": {
"title": "Direct Result",
"author": "Direct Author",
"content_type": "ebook",
"provider": "direct_download",
"provider_id": "dd-1",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "release",
},
"release_data": {
"source": "direct_download",
"source_id": "dd-1",
"title": "Direct Result.epub",
"format": "epub",
"size": "2 MB",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
resp = client.post("/api/requests", json=payload)
assert resp.status_code == 201
assert resp.json["request_level"] == "release"
assert resp.json["policy_mode"] == "request_book"
assert resp.json["release_data"]["source"] == "direct_download"
assert resp.json["release_data"]["source_id"] == "dd-1"
def test_non_admin_cannot_access_admin_request_routes(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)