diff --git a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts index c2a67d9b..8c1a7929 100644 --- a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts +++ b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts @@ -52,7 +52,7 @@ import { MIME, validateOPDSURL, } from '@/app/opds/utils/opdsUtils'; -import type { OPDSLink } from '@/types/opds'; +import type { OPDSBaseLink } from '@/types/opds'; import { fetchWithAuth } from '@/app/opds/utils/opdsReq'; const mockFetchWithAuth = vi.mocked(fetchWithAuth); @@ -195,70 +195,63 @@ describe('opdsUtils', () => { describe('isSearchLink', () => { it('should return true for a search link with OPENSEARCH type', () => { - const link: OPDSLink = { - rel: 'search', + const link: OPDSBaseLink = { + rel: ['search'], href: '/search', type: MIME.OPENSEARCH, - properties: {}, }; expect(isSearchLink(link)).toBe(true); }); it('should return true for a search link with ATOM type', () => { - const link: OPDSLink = { - rel: 'search', + const link: OPDSBaseLink = { + rel: ['search'], href: '/search', type: MIME.ATOM, - properties: {}, }; expect(isSearchLink(link)).toBe(true); }); it('should return true when rel is an array containing "search"', () => { - const link: OPDSLink = { + const link: OPDSBaseLink = { rel: ['self', 'search'], href: '/search', type: MIME.OPENSEARCH, - properties: {}, }; expect(isSearchLink(link)).toBe(true); }); it('should return false when rel does not include "search"', () => { - const link: OPDSLink = { - rel: 'self', + const link: OPDSBaseLink = { + rel: ['self'], href: '/search', type: MIME.OPENSEARCH, - properties: {}, }; expect(isSearchLink(link)).toBe(false); }); it('should return false when type is not OPENSEARCH or ATOM', () => { - const link: OPDSLink = { - rel: 'search', + const link: OPDSBaseLink = { + rel: ['search'], href: '/search', type: 'text/html', - properties: {}, }; expect(isSearchLink(link)).toBe(false); }); it('should return false when rel is undefined', () => { - const link: OPDSLink = { + const link: OPDSBaseLink = { href: '/search', type: MIME.OPENSEARCH, - properties: {}, }; expect(isSearchLink(link)).toBe(false); }); it('should return false when rel is an empty array', () => { - const link: OPDSLink = { + const link: OPDSBaseLink = { rel: [], href: '/search', type: MIME.ATOM, - properties: {}, }; expect(isSearchLink(link)).toBe(false); }); diff --git a/apps/readest-app/src/__tests__/services/opds/pseStream.test.ts b/apps/readest-app/src/__tests__/services/opds/pseStream.test.ts new file mode 100644 index 00000000..153ca8ce --- /dev/null +++ b/apps/readest-app/src/__tests__/services/opds/pseStream.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { + PSE_SCHEME, + buildPseStreamFileName, + parsePseStreamFileName, + isPseStreamFileName, +} from '@/services/opds/pseStream'; + +describe('pseStream', () => { + const sample = { + url: 'https://library.example.com/api/v1/series/42/page/{pageNumber}', + catalogId: 'catalog-1', + count: 120, + title: 'Sample Title', + author: 'Sample Author', + }; + + describe('buildPseStreamFileName', () => { + it('produces a pse:// URL', () => { + const name = buildPseStreamFileName(sample); + expect(name.startsWith(PSE_SCHEME)).toBe(true); + }); + + it('does not embed auth credentials or proxy URLs', () => { + const name = buildPseStreamFileName(sample); + const decoded = decodeURIComponent(name.replace(PSE_SCHEME, '')); + expect(decoded).not.toMatch(/Basic\s/i); + expect(decoded).not.toMatch(/auth=/); + expect(decoded).not.toMatch(/\/opds\/proxy/); + }); + }); + + describe('parsePseStreamFileName', () => { + it('round-trips through build', () => { + const parsed = parsePseStreamFileName(buildPseStreamFileName(sample)); + expect(parsed).toEqual(sample); + }); + }); + + describe('isPseStreamFileName', () => { + it('recognizes pse:// URLs', () => { + expect(isPseStreamFileName('pse://abc')).toBe(true); + }); + + it('rejects other URLs', () => { + expect(isPseStreamFileName('https://example.com')).toBe(false); + expect(isPseStreamFileName('book.cbz')).toBe(false); + expect(isPseStreamFileName('')).toBe(false); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/store/reader-store.test.ts b/apps/readest-app/src/__tests__/store/reader-store.test.ts index 20a4b4b2..67b2bcc5 100644 --- a/apps/readest-app/src/__tests__/store/reader-store.test.ts +++ b/apps/readest-app/src/__tests__/store/reader-store.test.ts @@ -55,6 +55,11 @@ vi.mock('@/services/constants', () => ({ vi.mock('@/libs/document', () => ({ DocumentLoader: vi.fn(), })); +vi.mock('@/services/opds/pseStream', () => ({ + isPseStreamFileName: () => false, + openPseStreamBook: vi.fn(), + parsePseStreamFileName: vi.fn(), +})); import { useReaderStore } from '@/store/readerStore'; import { useBookDataStore } from '@/store/bookDataStore'; diff --git a/apps/readest-app/src/__tests__/utils/opds-feed.test.ts b/apps/readest-app/src/__tests__/utils/opds-feed.test.ts index 85c0313f..7e7938fd 100644 --- a/apps/readest-app/src/__tests__/utils/opds-feed.test.ts +++ b/apps/readest-app/src/__tests__/utils/opds-feed.test.ts @@ -44,17 +44,17 @@ describe('OPDS feed parsing', () => { it('should separate navigation and publication entries in mixed feeds', () => { const doc = parseXML(copypartyRootFeed); - const feed = getFeed(doc); + const feed = getFeed(doc) as OPDSFeed; // Should have navigation items (the "Sub/" folder) expect(feed.navigation).toBeDefined(); expect(feed.navigation!).toHaveLength(1); - expect(feed.navigation![0].title).toBe('Sub/'); + expect(feed.navigation![0]!.title).toBe('Sub/'); // Should have publication items (the book) expect(feed.publications).toBeDefined(); expect(feed.publications!).toHaveLength(1); - expect(feed.publications![0].metadata.title).toBe( + expect(feed.publications![0]!.metadata.title).toBe( 'Children of the Fleet - Orson Scott Card.epub', ); }); @@ -82,15 +82,15 @@ describe('OPDS feed parsing', () => { `; const doc = parseXML(subFeed); - const feed = getFeed(doc); + const feed = getFeed(doc) as OPDSFeed; expect(feed.navigation).toBeUndefined(); expect(feed.publications).toBeDefined(); expect(feed.publications!).toHaveLength(2); - expect(feed.publications![0].metadata.title).toBe( + expect(feed.publications![0]!.metadata.title).toBe( 'Children of the Fleet - Orson Scott Card.epub', ); - expect(feed.publications![1].metadata.title).toBe( + expect(feed.publications![1]!.metadata.title).toBe( 'Earth Afire - Orson Scott Card & Aaron Johnston.epub', ); }); @@ -116,22 +116,22 @@ describe('OPDS feed parsing', () => { `; const doc = parseXML(navFeed); - const feed = getFeed(doc); + const feed = getFeed(doc) as OPDSFeed; expect(feed.publications).toBeUndefined(); expect(feed.navigation).toBeDefined(); expect(feed.navigation!).toHaveLength(2); - expect(feed.navigation![0].title).toBe('Fiction/'); - expect(feed.navigation![1].title).toBe('Non-Fiction/'); + expect(feed.navigation![0]!.title).toBe('Fiction/'); + expect(feed.navigation![1]!.title).toBe('Non-Fiction/'); }); it('should parse entry id and updated fields from publications', () => { const doc = parseXML(copypartyRootFeed); - const feed = getFeed(doc); + const feed = getFeed(doc) as OPDSFeed; // The book entry has an element but no expect(feed.publications).toBeDefined(); - expect(feed.publications![0].metadata.updated).toBe('2025-11-02T17:50:21Z'); + expect(feed.publications![0]!.metadata.updated).toBe('2025-11-02T17:50:21Z'); }); it('should handle navigation entry after publications', () => { @@ -155,15 +155,15 @@ describe('OPDS feed parsing', () => { `; const doc = parseXML(reverseFeed); - const feed = getFeed(doc); + const feed = getFeed(doc) as OPDSFeed; expect(feed.publications).toBeDefined(); expect(feed.publications!).toHaveLength(1); - expect(feed.publications![0].metadata.title).toBe('Some Book.epub'); + expect(feed.publications![0]!.metadata.title).toBe('Some Book.epub'); expect(feed.navigation).toBeDefined(); expect(feed.navigation!).toHaveLength(1); - expect(feed.navigation![0].title).toBe('Sub/'); + expect(feed.navigation![0]!.title).toBe('Sub/'); }); }); @@ -195,14 +195,14 @@ describe('OPDS feed parsing', () => { it('should parse id and updated on publications', () => { const doc = parseXML(shelfFeed); - const feed = getFeed(doc); + const feed = getFeed(doc) as OPDSFeed; expect(feed.publications).toHaveLength(2); - expect(feed.publications![0].metadata.id).toBe('urn:shelf:issue:abc123'); - expect(feed.publications![0].metadata.updated).toBe('2026-01-15T10:30:00.000Z'); - expect(feed.publications![0].metadata.published).toBe('2026-01-14T00:00:00.000Z'); - expect(feed.publications![1].metadata.id).toBe('urn:shelf:issue:def456'); - expect(feed.publications![1].metadata.updated).toBe('2026-01-16T08:00:00.000Z'); + expect(feed.publications![0]!.metadata.id).toBe('urn:shelf:issue:abc123'); + expect(feed.publications![0]!.metadata.updated).toBe('2026-01-15T10:30:00.000Z'); + expect(feed.publications![0]!.metadata.published).toBe('2026-01-14T00:00:00.000Z'); + expect(feed.publications![1]!.metadata.id).toBe('urn:shelf:issue:def456'); + expect(feed.publications![1]!.metadata.updated).toBe('2026-01-16T08:00:00.000Z'); }); it('should parse id and updated on the feed itself', () => { @@ -233,11 +233,11 @@ describe('OPDS feed parsing', () => { `; const doc = parseXML(minimalFeed); - const feed = getFeed(doc); + const feed = getFeed(doc) as OPDSFeed; expect(feed.publications).toHaveLength(1); - expect(feed.publications![0].metadata.id).toBeUndefined(); - expect(feed.publications![0].metadata.updated).toBeUndefined(); + expect(feed.publications![0]!.metadata.id).toBeUndefined(); + expect(feed.publications![0]!.metadata.updated).toBeUndefined(); }); }); }); diff --git a/apps/readest-app/src/app/opds/components/FeedView.tsx b/apps/readest-app/src/app/opds/components/FeedView.tsx index 948fb5fd..3823f160 100644 --- a/apps/readest-app/src/app/opds/components/FeedView.tsx +++ b/apps/readest-app/src/app/opds/components/FeedView.tsx @@ -4,7 +4,7 @@ import { useMemo, useCallback } from 'react'; import { VirtuosoGrid } from 'react-virtuoso'; import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5'; import { useTranslation } from '@/hooks/useTranslation'; -import { OPDSFeed, OPDSLink } from '@/types/opds'; +import { OPDSFeed, OPDSGenericLink } from '@/types/opds'; import { PublicationCard } from './PublicationCard'; import { NavigationCard } from './NavigationCard'; import { groupByArray } from '../utils/opdsUtils'; @@ -40,7 +40,7 @@ export function FeedView({ const hasFacets = feed.facets && feed.facets.length > 0; - const handlePaginationClick = (links?: OPDSLink[]) => { + const handlePaginationClick = (links?: OPDSGenericLink[]) => { if (links && links.length > 0) { const url = resolveURL(links[0]?.href || '', baseURL); onNavigate(url); @@ -94,8 +94,8 @@ export function FeedView({ )}