Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/__tests__/features/opds/useOpdsScreenOrchestration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type UseOpdsScreenOrchestrationParams,
useOpdsScreenOrchestration,
} from "@/features/opds/useOpdsScreenOrchestration";
import { savedCatalogsService } from "@/services/savedCatalogs";
import type { Publication } from "@/types/opds";

vi.mock("@/services/savedCatalogs", () => ({
savedCatalogsService: {
save: vi.fn(),
},
}));

const makePublication = (id: string): Publication => ({
id,
title: "Dune",
authors: [],
languages: ["en"],
categories: [],
relations: [],
descriptions: [],
identifiers: {},
links: [],
});

describe("useOpdsScreenOrchestration", () => {
let connection: UseOpdsScreenOrchestrationParams["connection"];
let downloads: UseOpdsScreenOrchestrationParams["downloads"];

beforeEach(() => {
connection = {
url: "https://example.com/opds",
username: "alice",
disconnect: vi.fn(),
};
downloads = {
clearDownloads: vi.fn(),
};
vi.clearAllMocks();
});

const render = () =>
renderHook((props: UseOpdsScreenOrchestrationParams) => useOpdsScreenOrchestration(props), {
initialProps: { connection, downloads },
});

describe("disconnect orchestration", () => {
it("disconnects the connection and clears the download registry", () => {
const { result } = render();

act(() => {
result.current.handleDisconnect();
});

expect(connection.disconnect).toHaveBeenCalledTimes(1);
expect(downloads.clearDownloads).toHaveBeenCalledTimes(1);
});
});

describe("save-catalog orchestration", () => {
it("persists the catalog with a non-empty fallback name and bumps the refresh key", async () => {
vi.mocked(savedCatalogsService.save).mockResolvedValue({
id: "cat-1",
name: "https://example.com/opds",
url: "https://example.com/opds",
username: "alice",
added_at: "2026-08-28T00:00:00Z",
});
const { result } = render();
expect(result.current.savedCatalogsKey).toBe(0);

await act(async () => {
await result.current.handleSaveCatalog();
});

expect(savedCatalogsService.save).toHaveBeenCalledWith(
"https://example.com/opds",
connection.url,
connection.username,
);
expect(result.current.savedCatalogsKey).toBe(1);
});

it("uses 'Untitled catalog' when the url trims to empty", async () => {
vi.mocked(savedCatalogsService.save).mockResolvedValue({
id: "cat-2",
name: "Untitled catalog",
url: "",
username: connection.username,
added_at: "2026-08-28T00:00:00Z",
});
const { result } = renderHook(
(props: UseOpdsScreenOrchestrationParams) => useOpdsScreenOrchestration(props),
{
initialProps: {
connection: { ...connection, url: " " },
downloads,
},
},
);

await act(async () => {
await result.current.handleSaveCatalog();
});

expect(savedCatalogsService.save).toHaveBeenCalledWith(
"Untitled catalog",
" ",
connection.username,
);
});

it("is best-effort: swallows a save failure without bumping the refresh key", async () => {
vi.mocked(savedCatalogsService.save).mockRejectedValue(new Error("backend down"));
const { result } = render();

await expect(result.current.handleSaveCatalog()).resolves.toBeUndefined();
expect(result.current.savedCatalogsKey).toBe(0);
});
});

describe("detail publication state", () => {
it("opens and closes the detail modal on the selected publication", () => {
const { result } = render();
expect(result.current.detailPublication).toBeNull();
const pub = makePublication("pub-detail");

act(() => {
result.current.openDetail(pub);
});
expect(result.current.detailPublication).toBe(pub);

act(() => {
result.current.closeDetail();
});
expect(result.current.detailPublication).toBeNull();
});
});
});
48 changes: 14 additions & 34 deletions src/features/opds/OpdsCatalogScreenContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import type React from "react";
import { useCallback, useState } from "react";
import { useOpdsCatalog } from "@/hooks/useOpdsCatalog";
import { savedCatalogsService } from "@/services/savedCatalogs";
import type { Publication } from "@/types/opds";
import { OpdsCatalogScreen } from "./OpdsCatalogScreen";
import { PublicationDetailModal } from "./PublicationDetailModal";
import { SavedCatalogsManager } from "./SavedCatalogsManager";
import { useCatalogConnection } from "./useCatalogConnection";
import { useDownloadRegistry } from "./useDownloadRegistry";
import { useOfflineLibraryState } from "./useOfflineLibraryState";
import { useOpdsDownload } from "./useOpdsDownload";
import { useOpdsScreenOrchestration } from "./useOpdsScreenOrchestration";

const OpdsCatalogScreenContainer: React.FC = () => {
const { status, error, localPath, mediaType, progress, startDownload } = useOpdsDownload();

const [detailPublication, setDetailPublication] = useState<Publication | null>(null);
const [savedCatalogsKey, setSavedCatalogsKey] = useState(0);

const connection = useCatalogConnection();
const offline = useOfflineLibraryState({
connected: connection.connected,
Expand All @@ -40,24 +35,7 @@ const OpdsCatalogScreenContainer: React.FC = () => {
connection.page,
connection.connected,
);

const handleDisconnect = useCallback(() => {
connection.disconnect();
downloads.clearDownloads();
}, [connection.disconnect, downloads.clearDownloads]);

const handleSaveCatalog = useCallback(async () => {
try {
await savedCatalogsService.save(
connection.url.trim() || "Untitled catalog",
connection.url,
connection.username,
);
setSavedCatalogsKey((k) => k + 1);
} catch {
// Non-fatal: saving the catalog is best-effort.
}
}, [connection]);
const orchestration = useOpdsScreenOrchestration({ connection, downloads });

return (
<>
Expand All @@ -70,7 +48,7 @@ const OpdsCatalogScreenContainer: React.FC = () => {
onPasswordChange={connection.setPassword}
connected={connection.connected}
onConnect={connection.connect}
onDisconnect={handleDisconnect}
onDisconnect={orchestration.handleDisconnect}
catalog={catalogQuery.data}
loading={catalogQuery.isFetching}
error={catalogQuery.error?.message ?? null}
Expand All @@ -87,27 +65,29 @@ const OpdsCatalogScreenContainer: React.FC = () => {
deletingRevisionId={offline.deletingRevisionId}
onDeleteLocal={offline.handleDeleteLocal}
onRefreshLibrary={offline.handleRefreshLibrary}
onViewDetails={setDetailPublication}
onSaveCatalog={handleSaveCatalog}
onViewDetails={orchestration.openDetail}
onSaveCatalog={orchestration.handleSaveCatalog}
savedCatalogs={
<SavedCatalogsManager
onConnectTo={connection.connectToSaved}
refreshKey={savedCatalogsKey}
refreshKey={orchestration.savedCatalogsKey}
/>
}
/>
{detailPublication && (
{orchestration.detailPublication && (
<PublicationDetailModal
publication={detailPublication}
onClose={() => setDetailPublication(null)}
publication={orchestration.detailPublication}
onClose={orchestration.closeDetail}
catalogUrl={connection.url.trim()}
transientUsername={connection.username}
transientPassword={connection.password}
contentRoot={connection.contentRoot}
onDownload={downloads.handleDownload}
downloadStatus={downloads.downloadStatuses[detailPublication.id]}
downloadErrorMessage={downloads.downloadErrors[detailPublication.id]}
libraryInfo={offline.libraryInfoByPublicationId[detailPublication.id] ?? null}
downloadStatus={downloads.downloadStatuses[orchestration.detailPublication.id]}
downloadErrorMessage={downloads.downloadErrors[orchestration.detailPublication.id]}
libraryInfo={
offline.libraryInfoByPublicationId[orchestration.detailPublication.id] ?? null
}
/>
)}
</>
Expand Down
1 change: 1 addition & 0 deletions src/features/opds/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export { useCatalogConnection } from "./useCatalogConnection";
export { useDownloadRegistry } from "./useDownloadRegistry";
export { useOfflineLibraryState } from "./useOfflineLibraryState";
export { useOpdsDownload } from "./useOpdsDownload";
export { useOpdsScreenOrchestration } from "./useOpdsScreenOrchestration";
export { usePublicationState } from "./usePublicationState";
62 changes: 62 additions & 0 deletions src/features/opds/useOpdsScreenOrchestration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useCallback, useState } from "react";
import { savedCatalogsService } from "@/services/savedCatalogs";
import type { Publication } from "@/types/opds";
import type { UseCatalogConnectionResult } from "./useCatalogConnection";
import type { UseDownloadRegistryResult } from "./useDownloadRegistry";

export interface UseOpdsScreenOrchestrationParams {
/** Catalog connection state; only the fields consumed here are used. */
connection: Pick<UseCatalogConnectionResult, "url" | "username" | "disconnect">;
/** Download registry; only the fields consumed here are used. */
downloads: Pick<UseDownloadRegistryResult, "clearDownloads">;
}

/**
* Screen-level orchestration for the OPDS catalog: the selected detail
* publication, the saved-catalog refresh trigger, and the disconnect /
* save-catalog callbacks that coordinate connection and download state.
*/
export function useOpdsScreenOrchestration({
connection,
downloads,
}: UseOpdsScreenOrchestrationParams) {
const [detailPublication, setDetailPublication] = useState<Publication | null>(null);
const [savedCatalogsKey, setSavedCatalogsKey] = useState(0);

const handleDisconnect = useCallback(() => {
connection.disconnect();
downloads.clearDownloads();
}, [connection.disconnect, downloads.clearDownloads]);

const handleSaveCatalog = useCallback(async () => {
try {
await savedCatalogsService.save(
connection.url.trim() || "Untitled catalog",
connection.url,
connection.username,
);
setSavedCatalogsKey((k) => k + 1);
} catch {
// Non-fatal: saving the catalog is best-effort.
}
}, [connection]);

const openDetail = useCallback((publication: Publication) => {
setDetailPublication(publication);
}, []);

const closeDetail = useCallback(() => {
setDetailPublication(null);
}, []);

return {
detailPublication,
openDetail,
closeDetail,
savedCatalogsKey,
handleDisconnect,
handleSaveCatalog,
};
}

export type UseOpdsScreenOrchestrationResult = ReturnType<typeof useOpdsScreenOrchestration>;
Loading