diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts new file mode 100644 index 000000000..6810cc3c1 --- /dev/null +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -0,0 +1,166 @@ +import { mkdirSync, readdirSync } from 'fs'; +import { join } from 'path'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + createMutableTextServer, + expect, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +async function openDownloadsPage(page: Page): Promise { + await page.getByRole('button', { name: 'Open downloads' }).click(); + await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); +} + +/** + * On a cold profile the renderer can query SQLite while the DB worker is + * still creating tables ("database is locked" / "no such table" on slow CI + * runners), which leaves the downloads page on its skeleton forever. Wait + * until a playlist read succeeds before navigating. + */ +async function waitForDatabaseReady(page: Page): Promise { + await expect + .poll( + () => + page.evaluate(async () => { + const electron = window.electron; + if (!electron) { + return false; + } + try { + await (electron.dbGetAppPlaylistMetas?.() ?? + electron.dbGetAppPlaylists?.()); + return true; + } catch { + return false; + } + }), + { timeout: 30000 } + ) + .toBe(true); +} + +test.describe('Electron Downloads', () => { + test('@downloads @electron shows the add-a-playlist empty state when no sources exist', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await waitForDatabaseReady(app.mainWindow); + await openDownloadsPage(app.mainWindow); + + await expect( + app.mainWindow.locator('.downloads__header h1') + ).toHaveText('Downloads'); + // First launch runs the IndexedDB→SQLite playlist migration before + // the playlists query resolves, so allow extra time here. + await expect( + app.mainWindow.getByText( + 'Add a playlist to start downloading' + ) + ).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('@downloads @electron completes a download end-to-end and manages it from the downloads page', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const fileServer = await createMutableTextServer( + 'e2e download payload', + { + contentType: 'video/mp4', + resourcePath: '/media/e2e-movie.mp4', + } + ); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Download Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await openDownloadsPage(app.mainWindow); + await expect( + app.mainWindow.getByText('No downloads yet') + ).toBeVisible(); + + // Authorize a folder inside the isolated data dir by stubbing the + // native folder dialog in the main process, then picking it via UI. + const downloadsDir = join(dataDir, 'e2e-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.locator('.downloads__folder-inline-path') + ).toContainText('e2e-downloads'); + + // Enqueue a download through the same renderer bridge the app uses. + const startResult = await app.mainWindow.evaluate( + async ({ url, folder }) => + window.electron?.downloadsStart?.({ + playlistId: 'e2e-playlist', + xtreamId: 4242, + contentType: 'vod', + title: 'E2E Movie', + url, + downloadFolder: folder, + }), + { url: fileServer.resourceUrl, folder: downloadsDir } + ); + expect(startResult?.error ?? null).toBeNull(); + + const item = app.mainWindow.locator('.downloads__item'); + await expect(item).toHaveCount(1, { timeout: 20000 }); + await expect( + item.locator('.downloads__item-title-text') + ).toHaveText('E2E Movie'); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Completed', + { timeout: 20000 } + ); + + // The file landed in the authorized folder. + expect(readdirSync(downloadsDir).length).toBeGreaterThan(0); + + // Completed items expose play / reveal / remove actions. + await expect( + item.getByRole('button').filter({ hasText: 'play_arrow' }) + ).toBeVisible(); + await expect( + item.getByRole('button').filter({ hasText: 'folder_open' }) + ).toBeVisible(); + + await item + .getByRole('button') + .filter({ hasText: 'delete' }) + .click(); + await expect( + app.mainWindow.getByText('No downloads yet') + ).toBeVisible(); + } finally { + await closeElectronApp(app); + await fileServer.close(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts new file mode 100644 index 000000000..7d214dbf2 --- /dev/null +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -0,0 +1,101 @@ +import { + addXtreamPortal, + channelItemByTitle, + clickCategoryByNameExact, + closeElectronApp, + expect, + launchElectronApp, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { fetchXtreamEpgFixture } from './portal-mock-fixtures'; + +const epgCredentials = { + username: 'epg', + password: 'epg', +}; + +test('@epg @xtream @electron opens the programme dialog from a timeline block and reacts to zoom', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const currentProgram = fixture.shortEpg[0]; + if (!currentProgram) { + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); + } + const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } }); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Xtream Timeline Interaction', + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + + const channelRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + await expect(channelRow).toBeVisible({ timeout: 20000 }); + await channelRow.click(); + + const timeline = app.mainWindow.locator('app-epg-timeline'); + await expect(timeline).toBeVisible({ timeout: 20000 }); + + const nowBlock = timeline + .locator('.epg-timeline__block.is-now') + .first(); + await expect(nowBlock).toBeVisible(); + + // Zooming re-renders the ribbon: block widths grow with px/minute. + const zoomInput = timeline.locator( + '.epg-timeline__zoom input[type="range"]' + ); + await expect(zoomInput).toBeVisible(); + + const blockWidthAt = async (zoom: 'min' | 'max') => { + await zoomInput.evaluate((element, target) => { + const input = element as HTMLInputElement; + input.value = target === 'min' ? input.min : input.max; + input.dispatchEvent(new Event('input', { bubbles: true })); + }, zoom); + const box = await nowBlock.boundingBox(); + return box?.width ?? 0; + }; + + const minZoomWidth = await blockWidthAt('min'); + const maxZoomWidth = await blockWidthAt('max'); + expect(maxZoomWidth).toBeGreaterThan(minZoomWidth); + + // At max zoom the on-air block is wide enough to expose the info + // affordance (hidden on narrow/micro tiers), which opens the shared + // programme-details dialog with the programme metadata. + await nowBlock.locator('.epg-timeline__info').click(); + + const dialog = app.mainWindow.locator('.epg-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('.epg-dialog__title')).toHaveText( + currentProgram.title + ); + // An on-air programme offers "watch live" as the primary action. + await expect( + dialog.locator('.epg-dialog__btn--primary') + ).toBeVisible(); + + await dialog.locator('.epg-dialog__close').click(); + await app.mainWindow.waitForSelector('.epg-dialog', { + state: 'detached', + }); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts new file mode 100644 index 000000000..cf6b3c49f --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts @@ -0,0 +1,165 @@ +import type { AppDatabase } from '../database.types'; + +/** + * Shared drizzle-orm query-builder mocks for the operations specs. + * Jest keeps a separate module registry per test file, so every spec gets + * its own mock instances. `jest.mock()` calls cannot live here (Jest hoists + * them per file), so each spec registers the module mock itself: + * + * jest.mock('drizzle-orm', () => mockDrizzleOrmModule()); + * + * The `mock` prefix on the exports is required so the hoisted factory is + * allowed to reference them. + */ +export const mockDrizzle = { + and: jest.fn((...conditions: unknown[]) => ({ + kind: 'and', + conditions, + })), + desc: jest.fn((value: unknown) => ({ kind: 'desc', value })), + eq: jest.fn((left: unknown, right: unknown) => ({ + kind: 'eq', + left, + right, + })), + inArray: jest.fn((left: unknown, values: unknown[]) => ({ + kind: 'inArray', + left, + values, + })), + sql: Object.assign( + jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings: Array.from(strings), + values, + })), + { + placeholder: jest.fn((name: string) => ({ + kind: 'placeholder', + name, + })), + } + ), +}; + +export function mockDrizzleOrmModule() { + return { + and: (...conditions: unknown[]) => mockDrizzle.and(...conditions), + desc: (value: unknown) => mockDrizzle.desc(value), + eq: (left: unknown, right: unknown) => mockDrizzle.eq(left, right), + inArray: (left: unknown, values: unknown[]) => + mockDrizzle.inArray(left, values), + sql: Object.assign( + (strings: TemplateStringsArray, ...values: unknown[]) => + mockDrizzle.sql(strings, ...values), + { + placeholder: (name: string) => + mockDrizzle.sql.placeholder(name), + } + ), + }; +} + +export function resetDrizzleMocks() { + mockDrizzle.and.mockClear(); + mockDrizzle.desc.mockClear(); + mockDrizzle.eq.mockClear(); + mockDrizzle.inArray.mockClear(); + mockDrizzle.sql.mockClear(); + mockDrizzle.sql.placeholder.mockClear(); +} + +export type QueryMock = { + from: jest.Mock; + innerJoin: jest.Mock; + where: jest.Mock; + orderBy: jest.Mock; + limit: jest.Mock; + then: ( + resolve: (value: unknown[]) => void, + reject: (reason: unknown) => void + ) => Promise; +}; + +/** + * Fluent AppDatabase mock. Each `db.select()` call consumes the next entry + * of `selectResultsByCall` as its resolved rows; every builder method + * returns the same chainable/thenable query object. + */ +export function createDbMock(selectResultsByCall: unknown[][] = []) { + let selectIndex = 0; + const queries: QueryMock[] = []; + const select = jest.fn(() => { + const rows = selectResultsByCall[selectIndex] ?? []; + selectIndex += 1; + const query: QueryMock = { + from: jest.fn(), + innerJoin: jest.fn(), + where: jest.fn(), + orderBy: jest.fn(), + limit: jest.fn().mockResolvedValue(rows), + then: (resolve, reject) => + Promise.resolve(rows).then(resolve, reject), + }; + query.from.mockReturnValue(query); + query.innerJoin.mockReturnValue(query); + query.where.mockReturnValue(query); + query.orderBy.mockReturnValue(query); + queries.push(query); + return query; + }); + + const insertValues = jest.fn().mockResolvedValue(undefined); + const insert = jest.fn().mockReturnValue({ values: insertValues }); + + const updateWhere = jest.fn().mockResolvedValue(undefined); + const updateSet = jest.fn().mockReturnValue({ where: updateWhere }); + const update = jest.fn().mockReturnValue({ set: updateSet }); + + const deleteExecute = jest.fn().mockResolvedValue(undefined); + const deletePrepare = jest + .fn() + .mockReturnValue({ execute: deleteExecute }); + const deleteResult = { + prepare: deletePrepare, + then: ( + resolve: (value: unknown) => void, + reject: (reason: unknown) => void + ) => Promise.resolve(undefined).then(resolve, reject), + }; + const deleteWhere = jest.fn().mockReturnValue(deleteResult); + const deleteFn = jest.fn().mockReturnValue({ + where: deleteWhere, + then: ( + resolve: (value: unknown) => void, + reject: (reason: unknown) => void + ) => Promise.resolve(undefined).then(resolve, reject), + }); + + const transaction = jest.fn((callback: () => unknown) => { + const result = callback(); + return Promise.resolve(result); + }); + + return { + db: { + select, + insert, + update, + delete: deleteFn, + transaction, + } as unknown as AppDatabase, + deleteExecute, + deleteFn, + deletePrepare, + deleteWhere, + insert, + insertValues, + queries, + select, + transaction, + update, + updateSet, + updateWhere, + }; +} diff --git a/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts new file mode 100644 index 000000000..1a919641d --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts @@ -0,0 +1,278 @@ +import { + createDbMock, + mockDrizzle, + mockDrizzleOrmModule, + resetDrizzleMocks, +} from './operations.test-helpers'; + +jest.mock('drizzle-orm', () => mockDrizzleOrmModule()); + +import * as schema from '@iptvnator/shared/database/schema'; +import { + clearAllPlaybackPositions, + clearPlaybackPosition, + getAllPlaybackPositions, + getPlaybackPosition, + getRecentPlaybackPositions, + getSeriesPlaybackPositions, + savePlaybackPosition, +} from './playback-position.operations'; + +describe('playback-position.operations', () => { + beforeEach(() => { + resetDrizzleMocks(); + }); + + describe('savePlaybackPosition', () => { + it('creates a stalker placeholder playlist before inserting a new position', async () => { + const { db, insert, insertValues } = createDbMock([[], []]); + + const result = await savePlaybackPosition(db, 'playlist-1', { + contentXtreamId: 500, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 3600, + }); + + expect(result).toEqual({ success: true }); + expect(insert).toHaveBeenNthCalledWith(1, schema.playlists); + expect(insert).toHaveBeenNthCalledWith( + 2, + schema.playbackPositions + ); + expect(insertValues).toHaveBeenNthCalledWith(1, { + id: 'playlist-1', + name: 'Imported Playlist', + type: 'stalker', + }); + expect(insertValues).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + playlistId: 'playlist-1', + contentXtreamId: 500, + contentType: 'vod', + positionSeconds: 120, + durationSeconds: 3600, + }) + ); + }); + + it('honors the provided playlist type when creating the missing playlist', async () => { + const { db, insertValues } = createDbMock([[], []]); + + await savePlaybackPosition(db, 'playlist-xt', { + contentXtreamId: 7, + contentType: 'vod', + positionSeconds: 10, + playlistType: 'xtream', + }); + + expect(insertValues).toHaveBeenNthCalledWith(1, { + id: 'playlist-xt', + name: 'Imported Playlist', + type: 'xtream', + }); + }); + + it('does not recreate a playlist that already exists', async () => { + const { db, insert } = createDbMock([[{ id: 'playlist-1' }], []]); + + await savePlaybackPosition(db, 'playlist-1', { + contentXtreamId: 500, + contentType: 'vod', + positionSeconds: 120, + }); + + expect(insert).toHaveBeenCalledTimes(1); + expect(insert).toHaveBeenCalledWith(schema.playbackPositions); + }); + + it('updates the existing row instead of inserting a duplicate position', async () => { + const { db, insert, update, updateSet } = createDbMock([ + [{ id: 'playlist-1' }], + [{ id: 33, positionSeconds: 15 }], + ]); + + const result = await savePlaybackPosition(db, 'playlist-1', { + contentXtreamId: 500, + contentType: 'episode', + seriesXtreamId: 42, + seasonNumber: 2, + episodeNumber: 5, + positionSeconds: 480, + }); + + expect(result).toEqual({ success: true }); + expect(insert).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith(schema.playbackPositions); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + seriesXtreamId: 42, + seasonNumber: 2, + episodeNumber: 5, + positionSeconds: 480, + }) + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.id, + 33 + ); + }); + + it('stamps updatedAt with CURRENT_TIMESTAMP on every save', async () => { + const { db, insertValues } = createDbMock([[], []]); + + await savePlaybackPosition(db, 'playlist-1', { + contentXtreamId: 500, + contentType: 'vod', + positionSeconds: 120, + }); + + expect(mockDrizzle.sql).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']); + expect(insertValues).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + updatedAt: expect.objectContaining({ kind: 'sql' }), + }) + ); + }); + }); + + describe('getPlaybackPosition', () => { + it('returns the matching row scoped by playlist, content, and type', async () => { + const row = { + id: 1, + playlistId: 'playlist-1', + contentXtreamId: 500, + positionSeconds: 99, + }; + const { db } = createDbMock([[row]]); + + const result = await getPlaybackPosition( + db, + 'playlist-1', + 500, + 'vod' + ); + + expect(result).toEqual(row); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.playlistId, + 'playlist-1' + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.contentXtreamId, + 500 + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.contentType, + 'vod' + ); + }); + + it('returns null when no position is stored', async () => { + const { db } = createDbMock([[]]); + + await expect( + getPlaybackPosition(db, 'playlist-1', 999, 'episode') + ).resolves.toBeNull(); + }); + }); + + describe('series and playlist queries', () => { + it('restricts series positions to episode rows for the series', async () => { + const rows = [{ id: 1 }, { id: 2 }]; + const { db, queries } = createDbMock([rows]); + + await expect( + getSeriesPlaybackPositions(db, 'playlist-1', 42) + ).resolves.toEqual(rows); + + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.seriesXtreamId, + 42 + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.contentType, + 'episode' + ); + expect(queries[0].limit).not.toHaveBeenCalled(); + }); + + it('returns recent positions newest-first with the default limit of 20', async () => { + const rows = [{ id: 3 }]; + const { db, queries } = createDbMock([rows]); + + await expect( + getRecentPlaybackPositions(db, 'playlist-1') + ).resolves.toEqual(rows); + + expect(mockDrizzle.desc).toHaveBeenCalledWith( + schema.playbackPositions.updatedAt + ); + expect(queries[0].limit).toHaveBeenCalledWith(20); + }); + + it('passes a custom limit through to the recent positions query', async () => { + const { db, queries } = createDbMock([[]]); + + await getRecentPlaybackPositions(db, 'playlist-1', 5); + + expect(queries[0].limit).toHaveBeenCalledWith(5); + }); + + it('returns all playlist positions without ordering or limits', async () => { + const rows = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const { db, queries } = createDbMock([rows]); + + await expect( + getAllPlaybackPositions(db, 'playlist-1') + ).resolves.toEqual(rows); + + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.playlistId, + 'playlist-1' + ); + expect(queries[0].orderBy).not.toHaveBeenCalled(); + expect(queries[0].limit).not.toHaveBeenCalled(); + }); + }); + + describe('clearing positions', () => { + it('clears every position of a playlist', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect( + clearAllPlaybackPositions(db, 'playlist-1') + ).resolves.toEqual({ success: true }); + + expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions); + expect(deleteWhere).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'eq' }) + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.playlistId, + 'playlist-1' + ); + }); + + it('clears a single content position scoped by playlist, content, and type', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect( + clearPlaybackPosition(db, 'playlist-1', 500, 'vod') + ).resolves.toEqual({ success: true }); + + expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions); + expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(3); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.contentXtreamId, + 500 + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.playbackPositions.contentType, + 'vod' + ); + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts new file mode 100644 index 000000000..a1a151a8c --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -0,0 +1,228 @@ +import { + createDbMock, + mockDrizzle, + mockDrizzleOrmModule, + resetDrizzleMocks, +} from './operations.test-helpers'; + +jest.mock('drizzle-orm', () => mockDrizzleOrmModule()); + +jest.mock('./content-backdrop.operations', () => ({ + persistContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), +})); + +import * as schema from '@iptvnator/shared/database/schema'; +import { persistContentBackdropIfMissing } from './content-backdrop.operations'; +import { + addRecentItem, + clearPlaylistRecentItems, + clearRecentlyViewed, + getRecentItems, + getRecentlyViewed, + removeRecentItem, + removeRecentItemsBatch, +} from './recently-viewed.operations'; + +describe('recently-viewed.operations', () => { + beforeEach(() => { + resetDrizzleMocks(); + (persistContentBackdropIfMissing as jest.Mock).mockClear(); + }); + + describe('reading recent items', () => { + it('returns the global history newest-first capped at 100 entries', async () => { + const rows = [ + { id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' }, + { id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' }, + ]; + const { db, queries } = createDbMock([rows]); + + await expect(getRecentlyViewed(db)).resolves.toEqual(rows); + + expect(mockDrizzle.desc).toHaveBeenCalledWith( + schema.recentlyViewed.viewedAt + ); + expect(queries[0].orderBy).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'desc' }) + ); + expect(queries[0].limit).toHaveBeenCalledWith(100); + expect(queries[0].innerJoin).toHaveBeenCalledTimes(3); + }); + + it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => { + const rows = [{ id: 5, title: 'Recent Movie' }]; + const { db, queries } = createDbMock([rows]); + + await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual( + rows + ); + + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.recentlyViewed.playlistId, + 'playlist-1' + ); + expect(mockDrizzle.desc).toHaveBeenCalledWith( + schema.recentlyViewed.viewedAt + ); + expect(queries[0].limit).toHaveBeenCalledWith(100); + }); + }); + + describe('addRecentItem', () => { + it('inserts a new history entry on first view and persists the backdrop', async () => { + const { db, insert, insertValues, update } = createDbMock([[]]); + + await expect( + addRecentItem(db, 42, 'playlist-1', { + backdropUrl: 'https://example.com/backdrop.jpg', + }) + ).resolves.toEqual({ success: true }); + + expect(insert).toHaveBeenCalledWith(schema.recentlyViewed); + expect(insertValues).toHaveBeenCalledWith({ + contentId: 42, + playlistId: 'playlist-1', + }); + expect(update).not.toHaveBeenCalled(); + expect(persistContentBackdropIfMissing).toHaveBeenCalledWith( + db, + 42, + 'https://example.com/backdrop.jpg' + ); + }); + + it('refreshes viewedAt for an already-tracked item instead of duplicating it', async () => { + const { db, insert, update, updateSet } = createDbMock([ + [{ id: 9, contentId: 42, playlistId: 'playlist-1' }], + ]); + + await expect( + addRecentItem(db, 42, 'playlist-1') + ).resolves.toEqual({ success: true }); + + expect(insert).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith(schema.recentlyViewed); + expect(updateSet).toHaveBeenCalledWith({ + viewedAt: expect.objectContaining({ kind: 'sql' }), + }); + expect(mockDrizzle.sql).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.recentlyViewed.contentId, + 42 + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.recentlyViewed.playlistId, + 'playlist-1' + ); + expect(persistContentBackdropIfMissing).toHaveBeenCalledWith( + db, + 42, + undefined + ); + }); + }); + + describe('clearing history', () => { + it('wipes the whole history table for the global clear', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect(clearRecentlyViewed(db)).resolves.toEqual({ + success: true, + }); + + expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed); + expect(deleteWhere).not.toHaveBeenCalled(); + }); + + it('deletes only entries belonging to the playlist content ids', async () => { + const { db, deleteFn, deleteWhere } = createDbMock([ + [{ id: 11 }, { id: 12 }], + ]); + + await expect( + clearPlaylistRecentItems(db, 'playlist-1') + ).resolves.toEqual({ success: true }); + + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.categories.playlistId, + 'playlist-1' + ); + expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed); + expect(mockDrizzle.inArray).toHaveBeenCalledWith( + schema.recentlyViewed.contentId, + [11, 12] + ); + expect(deleteWhere).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'inArray' }) + ); + }); + + it('skips the delete entirely when the playlist has no content', async () => { + const { db, deleteFn } = createDbMock([[]]); + + await expect( + clearPlaylistRecentItems(db, 'playlist-empty') + ).resolves.toEqual({ success: true }); + + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('removes a single entry scoped by content and playlist', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect( + removeRecentItem(db, 42, 'playlist-1') + ).resolves.toEqual({ success: true }); + + expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed); + expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(2); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.recentlyViewed.contentId, + 42 + ); + expect(mockDrizzle.eq).toHaveBeenCalledWith( + schema.recentlyViewed.playlistId, + 'playlist-1' + ); + }); + }); + + describe('removeRecentItemsBatch', () => { + it('returns a zero count without touching the database for empty input', async () => { + const { db, deleteFn, transaction } = createDbMock(); + + await expect(removeRecentItemsBatch(db, [])).resolves.toEqual({ + success: true, + count: 0, + }); + + expect(deleteFn).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('executes one prepared placeholder delete per item inside a transaction', async () => { + const { db, deleteExecute, deletePrepare, transaction } = + createDbMock(); + + await expect( + removeRecentItemsBatch(db, [ + { contentId: 1, playlistId: 'playlist-1' }, + { contentId: 2, playlistId: 'playlist-2' }, + ]) + ).resolves.toEqual({ success: true, count: 2 }); + + expect(deletePrepare).toHaveBeenCalledTimes(1); + expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('contentId'); + expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('playlistId'); + expect(transaction).toHaveBeenCalledTimes(1); + expect(deleteExecute).toHaveBeenNthCalledWith(1, { + contentId: 1, + playlistId: 'playlist-1', + }); + expect(deleteExecute).toHaveBeenNthCalledWith(2, { + contentId: 2, + playlistId: 'playlist-2', + }); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-worker.service.progress.spec.ts b/apps/electron-backend/src/app/events/epg-worker.service.progress.spec.ts new file mode 100644 index 000000000..58e52e566 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-worker.service.progress.spec.ts @@ -0,0 +1,237 @@ +/** + * Fetch-timeout and renderer progress-reporting coverage for EpgWorkerService. + * Worker lifecycle and deduplication live in `epg-worker.service.spec.ts`. + */ +const mockWorkerInstances: any[] = []; +const mockGetAllWindows = jest.fn((): any[] => []); +const mockResolveWorkerRuntimeBootstrap = jest.fn(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => mockGetAllWindows(), + }, +})); + +jest.mock('worker_threads', () => { + const { EventEmitter } = require('events'); + + class MockWorker extends EventEmitter { + postMessage = jest.fn(); + terminate = jest.fn().mockResolvedValue(0); + } + + return { + Worker: jest.fn().mockImplementation(() => { + const worker = new MockWorker(); + mockWorkerInstances.push(worker); + return worker; + }), + }; +}); + +jest.mock('../workers/worker-runtime-paths', () => ({ + resolveWorkerRuntimeBootstrap: (...args: unknown[]) => + mockResolveWorkerRuntimeBootstrap(...args), +})); + +import { EpgWorkerService } from './epg-worker.service'; + +describe('EpgWorkerService timeout and progress reporting', () => { + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + const url = 'https://example.com/guide.xml'; + + beforeEach(() => { + jest.clearAllMocks(); + mockWorkerInstances.length = 0; + mockGetAllWindows.mockReturnValue([]); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockResolveWorkerRuntimeBootstrap.mockReturnValue({ + workerPath: '/mock/workers/epg-parser.worker.js', + workerPathCandidates: ['/mock/workers/epg-parser.worker.js'], + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + } + + function createRendererWindow() { + return { webContents: { send: jest.fn() } }; + } + + describe('fetch timeout', () => { + it('rejects when the fetch times out without progress', async () => { + jest.useFakeTimers(); + const windows = [createRendererWindow()]; + mockGetAllWindows.mockReturnValue(windows); + + const service = new EpgWorkerService('[Test EPG]', 50); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + jest.advanceTimersByTime(50); + + await expect(fetchPromise).rejects.toThrow( + 'EPG fetch timed out after' + ); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(windows[0].webContents.send).toHaveBeenCalledWith( + 'EPG_PROGRESS_UPDATE', + expect.objectContaining({ + url, + status: 'error', + error: expect.stringContaining('timed out'), + }) + ); + }); + + it('ignores a late worker message after the timeout has settled', async () => { + jest.useFakeTimers(); + + const service = new EpgWorkerService('[Test EPG]', 50); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + jest.advanceTimersByTime(50); + await expect(fetchPromise).rejects.toThrow( + 'EPG fetch timed out after' + ); + + // A completion racing in after the timeout must not re-settle the + // promise or terminate the worker a second time. + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + expect(worker.terminate).toHaveBeenCalledTimes(1); + await expect(fetchPromise).rejects.toThrow( + 'EPG fetch timed out after' + ); + }); + + it('reschedules the timeout only when progress moves forward', async () => { + jest.useFakeTimers(); + + const service = new EpgWorkerService('[Test EPG]', 100); + const fetchPromise = service.fetchEpgFromUrl(url); + const fetchOutcome = fetchPromise.then( + () => 'resolved' as const, + (error: Error) => error.message + ); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + + jest.advanceTimersByTime(60); + worker.emit('message', { + type: 'EPG_PROGRESS', + stats: { totalChannels: 10, totalPrograms: 100 }, + }); + + // Moving progress rescheduled the timeout, so 60ms later the + // fetch is still alive even though 120ms passed overall. + jest.advanceTimersByTime(60); + expect(worker.terminate).not.toHaveBeenCalled(); + + // Stalled progress (same stats) must not reschedule the timeout. + worker.emit('message', { + type: 'EPG_PROGRESS', + stats: { totalChannels: 10, totalPrograms: 100 }, + }); + jest.advanceTimersByTime(60); + + await expect(fetchOutcome).resolves.toContain('timed out'); + expect(worker.terminate).toHaveBeenCalledTimes(1); + }); + }); + + describe('progress reporting', () => { + it('broadcasts progress updates to every renderer window', async () => { + const windows = [createRendererWindow(), createRendererWindow()]; + mockGetAllWindows.mockReturnValue(windows); + + const service = new EpgWorkerService('[Test EPG]', 1000); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_PROGRESS', + stats: { totalChannels: 5, totalPrograms: 50 }, + }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 5, totalPrograms: 55 }, + }); + await fetchPromise; + + for (const win of windows) { + const statuses = win.webContents.send.mock.calls.map( + ([channel, payload]: [string, { status: string }]) => { + expect(channel).toBe('EPG_PROGRESS_UPDATE'); + return payload.status; + } + ); + expect(statuses).toEqual(['loading', 'loading', 'complete']); + } + + expect(windows[0].webContents.send).toHaveBeenCalledWith( + 'EPG_PROGRESS_UPDATE', + expect.objectContaining({ + url, + status: 'complete', + stats: { totalChannels: 5, totalPrograms: 55 }, + }) + ); + }); + + it('forwards EPG_ERROR metadata to the renderer and rejects', async () => { + const windows = [createRendererWindow()]; + mockGetAllWindows.mockReturnValue(windows); + + const service = new EpgWorkerService('[Test EPG]', 1000); + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_ERROR', + error: 'untrusted host', + errorCode: 'UNTRUSTED_HOST', + errorHost: 'example.com', + }); + + await expect(fetchPromise).rejects.toThrow('untrusted host'); + await flushPromises(); + expect(windows[0].webContents.send).toHaveBeenCalledWith( + 'EPG_PROGRESS_UPDATE', + expect.objectContaining({ + url, + status: 'error', + error: 'untrusted host', + errorCode: 'UNTRUSTED_HOST', + errorHost: 'example.com', + }) + ); + expect(service.hasFetchedUrl(url)).toBe(false); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-worker.service.spec.ts b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts new file mode 100644 index 000000000..b86bfe67a --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-worker.service.spec.ts @@ -0,0 +1,221 @@ +/** + * Worker lifecycle and in-flight deduplication coverage for EpgWorkerService. + * Timeout and renderer progress reporting live in + * `epg-worker.service.progress.spec.ts`. + */ +const mockWorkerInstances: any[] = []; +const mockResolveWorkerRuntimeBootstrap = jest.fn(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => [], + }, +})); + +jest.mock('worker_threads', () => { + const { EventEmitter } = require('events'); + + class MockWorker extends EventEmitter { + postMessage = jest.fn(); + terminate = jest.fn().mockResolvedValue(0); + } + + return { + Worker: jest.fn().mockImplementation(() => { + const worker = new MockWorker(); + mockWorkerInstances.push(worker); + return worker; + }), + }; +}); + +jest.mock('../workers/worker-runtime-paths', () => ({ + resolveWorkerRuntimeBootstrap: (...args: unknown[]) => + mockResolveWorkerRuntimeBootstrap(...args), +})); + +import { EpgWorkerService } from './epg-worker.service'; + +describe('EpgWorkerService worker lifecycle', () => { + let service: EpgWorkerService; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + const url = 'https://example.com/guide.xml'; + + beforeEach(() => { + jest.clearAllMocks(); + mockWorkerInstances.length = 0; + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockResolveWorkerRuntimeBootstrap.mockReturnValue({ + workerPath: '/mock/workers/epg-parser.worker.js', + workerPathCandidates: ['/mock/workers/epg-parser.worker.js'], + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }); + service = new EpgWorkerService('[Test EPG]', 1000); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe('worker lifecycle', () => { + it('spawns the worker with bootstrap paths and drives the FETCH_EPG flow', async () => { + const options = { manuallyTrustedHosts: ['example.com'] } as any; + const fetchPromise = service.fetchEpgFromUrl(url, options); + const worker = mockWorkerInstances[0]; + const { Worker } = jest.requireMock('worker_threads'); + + expect(mockResolveWorkerRuntimeBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + isPackaged: false, + workerFilename: 'epg-parser.worker.js', + appPath: '/mock/app.asar', + }) + ); + expect(Worker).toHaveBeenCalledWith(expect.any(URL), { + resourceLimits: { + maxOldGenerationSizeMb: 4096, + maxYoungGenerationSizeMb: 512, + }, + workerData: { + nativeModuleSearchPaths: [ + '/mock/resources/app.asar.unpacked/node_modules', + ], + }, + }); + + worker.emit('message', { type: 'READY' }); + expect(worker.postMessage).toHaveBeenCalledWith({ + type: 'FETCH_EPG', + url, + options, + }); + + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 3, totalPrograms: 42 }, + }); + + await expect(fetchPromise).resolves.toBeUndefined(); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(service.hasFetchedUrl(url)).toBe(true); + }); + + it('rejects when the worker emits an error event', async () => { + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('error', new Error('worker crashed')); + + await expect(fetchPromise).rejects.toThrow('worker crashed'); + expect(worker.terminate).toHaveBeenCalledTimes(1); + expect(service.hasFetchedUrl(url)).toBe(false); + }); + + it('rejects when the worker exits before settling', async () => { + const fetchPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + + worker.emit('exit', 7); + + await expect(fetchPromise).rejects.toThrow( + 'Worker exited unexpectedly (code 7)' + ); + }); + + it('rejects and clears the in-flight entry when worker construction fails', async () => { + const { Worker } = jest.requireMock('worker_threads'); + Worker.mockImplementationOnce(() => { + throw new Error('cannot spawn worker'); + }); + + await expect(service.fetchEpgFromUrl(url)).rejects.toThrow( + 'cannot spawn worker' + ); + + // The failed fetch must not poison future fetches for the URL. + const retryPromise = service.fetchEpgFromUrl(url); + expect(mockWorkerInstances).toHaveLength(1); + + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + await expect(retryPromise).resolves.toBeUndefined(); + }); + }); + + describe('in-flight deduplication', () => { + it('shares one worker between concurrent fetches of the same URL', async () => { + const firstPromise = service.fetchEpgFromUrl(url); + const secondPromise = service.fetchEpgFromUrl(url); + + expect(mockWorkerInstances).toHaveLength(1); + + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + + await expect(firstPromise).resolves.toBeUndefined(); + await expect(secondPromise).resolves.toBeUndefined(); + expect(mockWorkerInstances).toHaveLength(1); + }); + + it('spawns separate workers for different URLs fetched concurrently', async () => { + const otherUrl = 'https://example.org/other-guide.xml'; + const firstPromise = service.fetchEpgFromUrl(url); + const secondPromise = service.fetchEpgFromUrl(otherUrl); + + expect(mockWorkerInstances).toHaveLength(2); + + for (const worker of mockWorkerInstances) { + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + } + + await expect(firstPromise).resolves.toBeUndefined(); + await expect(secondPromise).resolves.toBeUndefined(); + }); + + it('skips already fetched URLs until they are invalidated', async () => { + const firstPromise = service.fetchEpgFromUrl(url); + const worker = mockWorkerInstances[0]; + worker.emit('message', { type: 'READY' }); + worker.emit('message', { + type: 'EPG_COMPLETE', + stats: { totalChannels: 1, totalPrograms: 1 }, + }); + await firstPromise; + + await expect(service.fetchEpgFromUrl(url)).resolves.toBeUndefined(); + expect(mockWorkerInstances).toHaveLength(1); + + service.deleteFetchedUrl(url); + const refetchPromise = service.fetchEpgFromUrl(url); + expect(mockWorkerInstances).toHaveLength(2); + + // Settle the refetch so no timeout leaks past the test. + mockWorkerInstances[1].emit('exit', 0); + await expect(refetchPromise).rejects.toThrow( + 'Worker exited unexpectedly' + ); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index b04fe05b5..efbc14696 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -280,6 +280,11 @@ describe('EpgEvents', () => { releaseFetchTerminate(); await flushPromises(); expect(cleared).toBe(true); + + // Settle the interrupted fetch so its 1s timeout cannot fire (and + // log) after the suite has finished. + fetchWorker.emit('exit', 1); + await flushPromises(); }); it('clears one EPG source through a worker and allows it to be fetched again', async () => { diff --git a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts new file mode 100644 index 000000000..b78363f61 --- /dev/null +++ b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts @@ -0,0 +1,281 @@ +/** + * Instance reuse, process exit, and spawn-error coverage for the VLC session + * service. Pure helpers and launch-argument construction live in + * `vlc-session.service.spec.ts`. + */ +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +jest.mock('net', () => ({ + createConnection: jest.fn(), + createServer: jest.fn(), +})); + +jest.mock('../app', () => ({ + __esModule: true, + default: { + mainWindow: null, + }, +})); + +jest.mock('../services/store.service', () => ({ + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_PLAYER_PATH: 'VLC_PLAYER_PATH', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', + store: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +import { spawn, type ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import { createConnection, createServer } from 'net'; +import { + VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, + store, +} from '../services/store.service'; +import { externalPlayerSessions } from './external-player-runtime'; +import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service'; + +const spawnMock = spawn as unknown as jest.Mock; +const streamUrl = 'https://example.com/stream.m3u8'; +const rcWrites: string[] = []; + +function createMockChildProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + killed: false, + kill: jest.fn(() => true), + stderr: null, + stdout: null, + unref: jest.fn(), + }) as unknown as ChildProcess; +} + +async function waitForSpawnCallCount(count: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (spawnMock.mock.calls.length >= count) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Expected ${count} player spawn calls`); +} + +function mockStoreValues(values: Record): void { + (store.get as unknown as jest.Mock).mockImplementation( + (key: string, fallback?: unknown) => + key in values ? values[key] : fallback + ); +} + +function installRcSocketMock(behavior: 'ack' | 'error'): void { + (createConnection as unknown as jest.Mock).mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: jest.fn((data: string) => { + rcWrites.push(data); + setImmediate(() => socket.emit('data', Buffer.from('> '))); + return true; + }), + destroy: jest.fn(() => { + socket.destroyed = true; + }), + }); + setImmediate(() => { + if (behavior === 'error') { + socket.emit('error', new Error('rc connect failed')); + } else { + socket.emit('connect'); + } + }); + return socket; + }); +} + +async function openTrackedVlcInstance(proc: ChildProcess): Promise { + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ title: 'First', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + await openPromise; +} + +describe('vlc-session.service process lifecycle', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + rcWrites.length = 0; + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + (createServer as unknown as jest.Mock).mockImplementation(() => ({ + unref: jest.fn(), + on: jest.fn(), + listen: (_port: number, _host: string, cb: () => void) => cb(), + address: () => ({ port: 43210 }), + close: (cb?: () => void) => cb?.(), + })); + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: false, + }); + }); + + afterEach(() => { + // Drop any process tracked for reuse so tests stay isolated. + shutdownVlcSession(); + consoleErrorSpy.mockRestore(); + }); + + describe('instance reuse', () => { + beforeEach(() => { + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + }); + }); + + it('reuses the tracked VLC instance through RC commands', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + installRcSocketMock('ack'); + + const session = await openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + referer: 'https://ref.example', + }); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(rcWrites).toEqual([ + 'clear\n', + 'add https://example.com/two.m3u8 ' + + ':http-referrer=https://ref.example :meta-title=Second\n', + ]); + expect(session.status).toBe('opened'); + }); + + it('kills the stale instance and spawns fresh when RC reuse fails', async () => { + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + installRcSocketMock('error'); + + const freshProc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(freshProc); + const openPromise = openVlcPlayer({ + title: 'Second', + url: 'https://example.com/two.m3u8', + }); + await waitForSpawnCallCount(2); + freshProc.emit('spawn'); + const session = await openPromise; + + expect(proc.kill).toHaveBeenCalled(); + expect(session.status).toBe('opened'); + }); + }); + + describe('process exit handling', () => { + it('marks the session closed on a clean exit', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await openPromise; + + proc.emit('exit', 0); + expect(externalPlayerSessions.getSession(session.id)?.status).toBe( + 'closed' + ); + }); + + it('marks the session as errored on an unexpected exit code', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await openPromise; + + proc.emit('exit', 2); + const updated = externalPlayerSessions.getSession(session.id); + expect(updated?.status).toBe('error'); + expect(updated?.error).toContain('exit code: 2'); + }); + + it('retries without the RC interface when VLC exits with code 1', async () => { + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + }); + const proc = createMockChildProcess(); + await openTrackedVlcInstance(proc); + + const retryProc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(retryProc); + proc.emit('exit', 1); + await waitForSpawnCallCount(2); + + const retryArgs = spawnMock.mock.calls[1][1] as string[]; + expect(retryArgs.join(' ')).not.toContain('--extraintf'); + expect(retryArgs.join(' ')).not.toContain('--rc-host'); + // Retry processes are never tracked for reuse. + expect(spawnMock.mock.calls[1][2]).toMatchObject({ + detached: true, + stdio: 'ignore', + }); + expect(retryProc.unref).toHaveBeenCalled(); + }); + }); + + describe('spawn error handling', () => { + it('rejects with an actionable error when VLC fails to start', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); + const sessionId = externalPlayerSessions.getActiveSessionId(); + await waitForSpawnCallCount(1); + + proc.emit('error', new Error('boom')); + + await expect(openPromise).rejects.toThrow( + "Failed to start VLC player: boom. Make sure VLC is installed and the path '/usr/bin/vlc' is correct." + ); + expect( + externalPlayerSessions.getSession(sessionId as string)?.status + ).toBe('error'); + }); + + it('retries without the RC interface after a start error', async () => { + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + }); + const proc = createMockChildProcess(); + const retryProc = createMockChildProcess(); + spawnMock + .mockReturnValueOnce(proc) + .mockReturnValueOnce(retryProc); + + const openPromise = openVlcPlayer({ title: 'S', url: streamUrl }); + await waitForSpawnCallCount(1); + proc.emit('error', new Error('rc unsupported')); + await waitForSpawnCallCount(2); + + const retryArgs = spawnMock.mock.calls[1][1] as string[]; + expect(retryArgs.join(' ')).not.toContain('--extraintf'); + retryProc.emit('spawn'); + const session = await openPromise; + expect(session.status).toBe('opened'); + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts new file mode 100644 index 000000000..1c80160ea --- /dev/null +++ b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts @@ -0,0 +1,246 @@ +/** + * Pure helper and launch-argument coverage for the VLC session service. + * Instance reuse, process exit, and spawn-error handling live in + * `vlc-session.service.lifecycle.spec.ts`. + */ +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +jest.mock('net', () => ({ + createConnection: jest.fn(), + createServer: jest.fn(), +})); + +jest.mock('../app', () => ({ + __esModule: true, + default: { + mainWindow: null, + }, +})); + +jest.mock('../services/store.service', () => ({ + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_PLAYER_PATH: 'VLC_PLAYER_PATH', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', + store: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +import { spawn, type ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import { createServer } from 'net'; +import { + VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, + store, +} from '../services/store.service'; +import { + buildVlcEnqueueCommands, + openVlcPlayer, + parseVlcRcNumericResponse, + parseVlcRcPlaybackState, + shutdownVlcSession, +} from './vlc-session.service'; + +const spawnMock = spawn as unknown as jest.Mock; +const streamUrl = 'https://example.com/stream.m3u8'; + +function createMockChildProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + killed: false, + kill: jest.fn(() => true), + stderr: null, + stdout: null, + unref: jest.fn(), + }) as unknown as ChildProcess; +} + +async function waitForSpawnCallCount(count: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (spawnMock.mock.calls.length >= count) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Expected ${count} player spawn calls`); +} + +function mockStoreValues(values: Record): void { + (store.get as unknown as jest.Mock).mockImplementation( + (key: string, fallback?: unknown) => + key in values ? values[key] : fallback + ); +} + +describe('vlc-session.service helpers and launch args', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + (createServer as unknown as jest.Mock).mockImplementation(() => ({ + unref: jest.fn(), + on: jest.fn(), + listen: (_port: number, _host: string, cb: () => void) => cb(), + address: () => ({ port: 43210 }), + close: (cb?: () => void) => cb?.(), + })); + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: false, + }); + }); + + afterEach(() => { + // Drop any process tracked for reuse so tests stay isolated. + shutdownVlcSession(); + consoleErrorSpy.mockRestore(); + }); + + describe('buildVlcEnqueueCommands', () => { + it('builds clear/add/seek commands with all input options', () => { + expect( + buildVlcEnqueueCommands({ + url: 'http://srv/1', + title: 'My Title', + userAgent: 'UA/1.0', + referer: 'https://ref.example', + headers: { 'X-A': ' padded ', 'X-Empty': ' ' }, + startTime: 12.7, + }) + ).toEqual([ + 'clear', + 'add http://srv/1 :http-user-agent=UA/1.0 ' + + ':http-referrer=https://ref.example ' + + ':http-header=X-A: padded :meta-title=My Title', + 'seek 12', + ]); + }); + + it('falls back to origin as referrer and omits empty options', () => { + expect( + buildVlcEnqueueCommands({ + url: 'http://srv/2', + origin: 'https://origin.example', + }) + ).toEqual([ + 'clear', + 'add http://srv/2 :http-referrer=https://origin.example', + ]); + expect(buildVlcEnqueueCommands({ url: 'http://srv/3' })).toEqual([ + 'clear', + 'add http://srv/3', + ]); + }); + }); + + describe('RC response parsing', () => { + it('extracts numeric RC responses', () => { + expect(parseVlcRcNumericResponse('status change: > 123')).toBe( + '123' + ); + expect(parseVlcRcNumericResponse('> -4.5')).toBe('-4.5'); + expect(parseVlcRcNumericResponse('no prompt here')).toBe(''); + }); + + it('extracts the playback state', () => { + expect(parseVlcRcPlaybackState('( state Stopped )')).toBe( + 'stopped' + ); + expect(parseVlcRcPlaybackState('garbage')).toBeNull(); + }); + }); + + describe('openVlcPlayer launch args', () => { + it('spawns a detached VLC process with http options and start time', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + + const openPromise = openVlcPlayer({ + title: 'My Stream', + url: streamUrl, + userAgent: 'UA/1.0', + referer: 'https://ref.example/page', + headers: { 'X-Test': 'yes' }, + startTime: 90, + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + const session = await openPromise; + + expect(spawnMock).toHaveBeenCalledWith( + '/usr/bin/vlc', + [ + ':http-user-agent=UA/1.0', + ':http-referrer=https://ref.example/page', + ':http-header=X-Test: yes', + '--start-time=90', + streamUrl, + ':meta-title=My Stream', + ], + { shell: false, detached: true, stdio: 'ignore' } + ); + expect(proc.unref).toHaveBeenCalled(); + expect(session.status).toBe('opened'); + }); + + it('uses the origin as referrer fallback when no referer is given', async () => { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + + const openPromise = openVlcPlayer({ + title: 'Origin Stream', + url: streamUrl, + origin: 'https://origin.example', + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + await openPromise; + + expect(spawnMock.mock.calls[0][1]).toEqual([ + ':http-referrer=https://origin.example', + streamUrl, + ':meta-title=Origin Stream', + ]); + }); + + it('adds the RC interface and tracks the process when reuse is enabled', async () => { + mockStoreValues({ + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: true, + }); + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + + const openPromise = openVlcPlayer({ + title: 'First', + url: streamUrl, + }); + await waitForSpawnCallCount(1); + proc.emit('spawn'); + await openPromise; + + expect(spawnMock.mock.calls[0][1]).toEqual([ + '--extraintf=rc', + '--rc-host=127.0.0.1:43210', + streamUrl, + ':meta-title=First', + ]); + expect(spawnMock.mock.calls[0][2]).toEqual({ + shell: false, + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + expect(proc.unref).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/electron-backend/tsconfig.app.json b/apps/electron-backend/tsconfig.app.json index 46b5f34af..784ccb3b5 100644 --- a/apps/electron-backend/tsconfig.app.json +++ b/apps/electron-backend/tsconfig.app.json @@ -9,10 +9,12 @@ "**/*.spec.ts", "**/*.spec-data.ts", "**/*.test.ts", + "**/*.test-helpers.ts", "jest.config.ts", "src/**/*.spec.ts", "src/**/*.spec-data.ts", - "src/**/*.test.ts" + "src/**/*.test.ts", + "src/**/*.test-helpers.ts" ], "include": ["**/*.ts"] } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts new file mode 100644 index 000000000..474141b86 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts @@ -0,0 +1,192 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerContentType } from '../stalker-store.contracts'; +import { withStalkerFavorites } from './with-stalker-favorites.feature'; + +const PLAYLIST = { + _id: 'portal-1', + title: 'Demo Stalker', + count: 0, + autoRefresh: false, + importDate: '2026-04-14T00:00:00.000Z', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', +} as PlaylistMeta; + +const TestFavoritesStore = signalStore( + withState<{ + currentPlaylist: PlaylistMeta | undefined; + selectedContentType: StalkerContentType; + }>({ + currentPlaylist: PLAYLIST, + selectedContentType: 'vod', + }), + withMethods((store) => ({ + setCurrentPlaylist(playlist: PlaylistMeta | undefined) { + patchState(store, { currentPlaylist: playlist }); + }, + setSelectedContentType(type: StalkerContentType) { + patchState(store, { selectedContentType: type }); + }, + })), + withStalkerFavorites() +); + +describe('withStalkerFavorites', () => { + let store: InstanceType; + let playlistService: { + addPortalFavorite: jest.Mock; + removeFromPortalFavorites: jest.Mock; + }; + let snackBar: { open: jest.Mock }; + let ngrxStore: { dispatch: jest.Mock }; + + beforeEach(() => { + playlistService = { + addPortalFavorite: jest.fn(() => + of({ favorites: [{ id: '42', title: 'Movie Title' }] }) + ), + removeFromPortalFavorites: jest.fn(() => + of({ favorites: [] }) + ), + }; + snackBar = { open: jest.fn() }; + ngrxStore = { dispatch: jest.fn() }; + + TestBed.configureTestingModule({ + providers: [ + TestFavoritesStore, + { provide: PlaylistsService, useValue: playlistService }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: jest.fn((key: string) => key) }, + }, + { provide: Store, useValue: ngrxStore }, + ], + }); + + store = TestBed.inject(TestFavoritesStore); + }); + + describe('addToFavorites', () => { + it('persists the favorite with a normalized payload and syncs playlist meta', () => { + const onDone = jest.fn(); + + store.addToFavorites( + { + stream_id: 42, + id: 'ignored-id', + name: 'Movie Title', + category_id: '17', + }, + onDone + ); + + expect(playlistService.addPortalFavorite).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ + id: 42, + name: 'Movie Title', + category_id: '17', + added_at: expect.any(Number), + }) + ); + expect(ngrxStore.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: 'portal-1', + favorites: [{ id: '42', title: 'Movie Title' }], + } as PlaylistMeta, + }) + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'PORTALS.ADDED_TO_FAVORITES', + undefined, + { duration: 1000 } + ); + expect(onDone).toHaveBeenCalled(); + }); + + it('falls back to the selected content type when the category id is blank', () => { + store.setSelectedContentType('itv'); + + store.addToFavorites({ id: '9', category_id: ' ' }); + + expect(playlistService.addPortalFavorite).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ + id: '9', + category_id: 'itv', + }) + ); + }); + + it('falls back to the item id when no stream id is present', () => { + store.addToFavorites({ id: '77', name: 'Channel' }); + + expect(playlistService.addPortalFavorite).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ id: '77' }) + ); + }); + + it('does nothing when no portal playlist is active', () => { + const onDone = jest.fn(); + store.setCurrentPlaylist(undefined); + + store.addToFavorites({ id: '9' }, onDone); + + expect(playlistService.addPortalFavorite).not.toHaveBeenCalled(); + expect(ngrxStore.dispatch).not.toHaveBeenCalled(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + }); + }); + + describe('removeFromFavorites', () => { + it('removes the favorite and syncs the emptied playlist meta', () => { + const onDone = jest.fn(); + + store.removeFromFavorites('42', onDone); + + expect( + playlistService.removeFromPortalFavorites + ).toHaveBeenCalledWith('portal-1', '42'); + expect(ngrxStore.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: 'portal-1', + favorites: [], + } as PlaylistMeta, + }) + ); + expect(snackBar.open).toHaveBeenCalledWith( + 'PORTALS.REMOVED_FROM_FAVORITES', + undefined, + { duration: 1000 } + ); + expect(onDone).toHaveBeenCalled(); + }); + + it('does nothing when no portal playlist is active', () => { + const onDone = jest.fn(); + store.setCurrentPlaylist(undefined); + + store.removeFromFavorites('42', onDone); + + expect( + playlistService.removeFromPortalFavorites + ).not.toHaveBeenCalled(); + expect(ngrxStore.dispatch).not.toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.ts new file mode 100644 index 000000000..2e4ad1908 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.ts @@ -0,0 +1,209 @@ +import { TestBed } from '@angular/core/testing'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { Store } from '@ngrx/store'; +import { of, throwError } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { PlaylistsService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerContentType } from '../stalker-store.contracts'; +import { withStalkerRecent } from './with-stalker-recent.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + ...jest.requireActual('@iptvnator/portal/shared/util'), + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const PLAYLIST = { + _id: 'portal-1', + title: 'Demo Stalker', + count: 0, + autoRefresh: false, + importDate: '2026-04-14T00:00:00.000Z', + portalUrl: 'http://demo.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:01', +} as PlaylistMeta; + +const TestRecentStore = signalStore( + withState<{ + currentPlaylist: PlaylistMeta | undefined; + selectedContentType: StalkerContentType; + }>({ + currentPlaylist: PLAYLIST, + selectedContentType: 'vod', + }), + withMethods((store) => ({ + setCurrentPlaylist(playlist: PlaylistMeta | undefined) { + patchState(store, { currentPlaylist: playlist }); + }, + setSelectedContentType(type: StalkerContentType) { + patchState(store, { selectedContentType: type }); + }, + })), + withStalkerRecent() +); + +describe('withStalkerRecent', () => { + let store: InstanceType; + let playlistService: { + addPortalRecentlyViewed: jest.Mock; + removeFromPortalRecentlyViewed: jest.Mock; + }; + let ngrxStore: { dispatch: jest.Mock }; + + beforeEach(() => { + playlistService = { + addPortalRecentlyViewed: jest.fn(() => + of({ recentlyViewed: [{ id: '22', title: 'Movie Title' }] }) + ), + removeFromPortalRecentlyViewed: jest.fn(() => + of({ recentlyViewed: [] }) + ), + }; + ngrxStore = { dispatch: jest.fn() }; + + TestBed.configureTestingModule({ + providers: [ + TestRecentStore, + { provide: PlaylistsService, useValue: playlistService }, + { provide: Store, useValue: ngrxStore }, + ], + }); + + store = TestBed.inject(TestRecentStore); + }); + + describe('addToRecentlyViewed', () => { + it('persists a normalized recent item and syncs playlist meta', () => { + store.addToRecentlyViewed({ + id: ' 22 ', + name: 'Movie Title', + category_id: '', + }); + + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ + id: '22', + title: 'Movie Title', + category_id: 'vod', + added_at: expect.any(Number), + }) + ); + expect(ngrxStore.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: 'portal-1', + recentlyViewed: [{ id: '22', title: 'Movie Title' }], + } as PlaylistMeta, + }) + ); + }); + + it('forces the series category and falls back to stream_id and o_name', () => { + store.setSelectedContentType('series'); + + store.addToRecentlyViewed({ + stream_id: 30000, + o_name: 'Original Series Name', + category_id: '99', + }); + + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ + id: '30000', + title: 'Original Series Name', + category_id: 'series', + }) + ); + }); + + it('keeps a provided category id for non-series content', () => { + store.setSelectedContentType('itv'); + + store.addToRecentlyViewed({ + id: '5', + title: 'Channel Five', + category_id: '4001', + }); + + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledWith( + 'portal-1', + expect.objectContaining({ + id: '5', + title: 'Channel Five', + category_id: '4001', + }) + ); + }); + + it('does nothing when no portal playlist is active', () => { + store.setCurrentPlaylist(undefined); + + store.addToRecentlyViewed({ id: '22', title: 'Movie Title' }); + + expect( + playlistService.addPortalRecentlyViewed + ).not.toHaveBeenCalled(); + expect(ngrxStore.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('removeFromRecentlyViewed', () => { + it('removes the item, syncs playlist meta, and signals completion', () => { + const onComplete = jest.fn(); + + store.removeFromRecentlyViewed('22', onComplete); + + expect( + playlistService.removeFromPortalRecentlyViewed + ).toHaveBeenCalledWith('portal-1', '22'); + expect(ngrxStore.dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: 'portal-1', + recentlyViewed: [], + } as PlaylistMeta, + }) + ); + expect(onComplete).toHaveBeenCalled(); + }); + + it('swallows removal errors without syncing meta or completing', () => { + const onComplete = jest.fn(); + playlistService.removeFromPortalRecentlyViewed.mockReturnValue( + throwError(() => new Error('portal unreachable')) + ); + + expect(() => + store.removeFromRecentlyViewed('22', onComplete) + ).not.toThrow(); + + expect(ngrxStore.dispatch).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('does nothing when no portal playlist is active', () => { + const onComplete = jest.fn(); + store.setCurrentPlaylist(undefined); + + store.removeFromRecentlyViewed('22', onComplete); + + expect( + playlistService.removeFromPortalRecentlyViewed + ).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts new file mode 100644 index 000000000..983a85c21 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts @@ -0,0 +1,177 @@ +import { + ElectronXtreamDataSourceHarness, + setupElectronXtreamDataSource, +} from './electron-xtream-data-source.test-helpers'; + +/** + * Delegation tests for ElectronXtreamDataSource: playlist, category, and + * content methods that forward to the DatabaseService over the IPC boundary. + * User-data delegation (favorites, recently viewed, playback positions, + * cleanup) is covered in electron-xtream-data-source.user-data.spec.ts and + * the DB-first fetch/cache strategy in electron-xtream-data-source.spec.ts. + */ +describe('ElectronXtreamDataSource (delegation)', () => { + let harness: ElectronXtreamDataSourceHarness; + + const playlistId = 'playlist-1'; + + beforeEach(() => { + harness = setupElectronXtreamDataSource(); + }); + + describe('playlist operations', () => { + it('reads playlists from the DB by id', async () => { + const playlist = { _id: playlistId, title: 'My Portal' }; + harness.dbService.getPlaylistById.mockResolvedValue(playlist); + + await expect( + harness.dataSource.getPlaylist(playlistId) + ).resolves.toEqual(playlist); + expect(harness.dbService.getPlaylistById).toHaveBeenCalledWith( + playlistId + ); + }); + + it('maps playlist fields to DB shape on create', async () => { + await harness.dataSource.createPlaylist({ + id: playlistId, + name: 'My Portal', + serverUrl: 'http://portal.example', + username: 'demo', + password: 'secret', + type: 'xtream', + }); + + expect(harness.dbService.createPlaylist).toHaveBeenCalledWith({ + _id: playlistId, + title: 'My Portal', + serverUrl: 'http://portal.example', + username: 'demo', + password: 'secret', + }); + }); + + it('maps update fields to the Xtream playlist details payload', async () => { + await harness.dataSource.updatePlaylist(playlistId, { + name: 'Renamed Portal', + username: 'new-user', + password: 'new-pass', + serverUrl: 'http://new.example', + }); + + expect( + harness.dbService.updateXtreamPlaylistDetails + ).toHaveBeenCalledWith({ + id: playlistId, + title: 'Renamed Portal', + username: 'new-user', + password: 'new-pass', + serverUrl: 'http://new.example', + }); + }); + + it('deletes playlists via the DB and propagates failures', async () => { + await harness.dataSource.deletePlaylist(playlistId); + expect(harness.dbService.deletePlaylist).toHaveBeenCalledWith( + playlistId + ); + + harness.dbService.deletePlaylist.mockRejectedValue( + new Error('delete failed') + ); + await expect( + harness.dataSource.deletePlaylist(playlistId) + ).rejects.toThrow('delete failed'); + }); + }); + + describe('category and content pass-throughs', () => { + it('delegates category reads and writes to the DB', async () => { + harness.dbService.hasXtreamCategories.mockResolvedValue(true); + const categories = [{ id: 1, name: 'News' }]; + harness.dbService.getAllXtreamCategories.mockResolvedValue( + categories + ); + harness.dbService.getXtreamCategories.mockResolvedValue(categories); + + await expect( + harness.dataSource.hasCategories(playlistId, 'live') + ).resolves.toBe(true); + await expect( + harness.dataSource.getAllCategories(playlistId, 'series') + ).resolves.toEqual(categories); + // getCachedCategories maps API type 'vod' to DB type 'movies' + await expect( + harness.dataSource.getCachedCategories(playlistId, 'vod') + ).resolves.toEqual(categories); + expect(harness.dbService.getXtreamCategories).toHaveBeenCalledWith( + playlistId, + 'movies' + ); + + await harness.dataSource.saveCategories( + playlistId, + categories as never, + 'live' + ); + expect(harness.dbService.saveXtreamCategories).toHaveBeenCalledWith( + playlistId, + categories, + 'live' + ); + + await harness.dataSource.updateCategoryVisibility([1, 2], true); + expect( + harness.dbService.updateCategoryVisibility + ).toHaveBeenCalledWith([1, 2], true); + }); + + it('delegates content reads, writes, and search to the DB', async () => { + const items = [{ id: 1, title: 'Movie One', xtream_id: 202 }]; + harness.dbService.hasXtreamContent.mockResolvedValue(true); + harness.dbService.getXtreamContent.mockResolvedValue(items); + harness.dbService.saveXtreamContent.mockResolvedValue(1); + harness.dbService.searchXtreamContent.mockResolvedValue(items); + const onProgress = jest.fn(); + const options = { operationId: 'op-1' }; + + await expect( + harness.dataSource.hasContent(playlistId, 'movie') + ).resolves.toBe(true); + await expect( + harness.dataSource.getCachedContent(playlistId, 'movie') + ).resolves.toEqual(items); + await expect( + harness.dataSource.saveContent( + playlistId, + items as never, + 'movie', + onProgress, + options + ) + ).resolves.toBe(1); + expect(harness.dbService.saveXtreamContent).toHaveBeenCalledWith( + playlistId, + items, + 'movie', + onProgress, + options + ); + + await expect( + harness.dataSource.searchContent( + playlistId, + 'movie one', + ['movie', 'series'], + true + ) + ).resolves.toEqual(items); + expect(harness.dbService.searchXtreamContent).toHaveBeenCalledWith( + playlistId, + 'movie one', + ['movie', 'series'], + true + ); + }); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts new file mode 100644 index 000000000..a4c157a62 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts @@ -0,0 +1,350 @@ +import { + credentials, + ElectronXtreamDataSourceHarness, + setupElectronXtreamDataSource, +} from './electron-xtream-data-source.test-helpers'; + +/** + * DB-first strategy tests for ElectronXtreamDataSource: + * DB hit (no API call), DB miss (API fetch + cache), request deduplication, + * and error propagation. Delegation-only methods are covered in + * electron-xtream-data-source.delegation.spec.ts. + */ +describe('ElectronXtreamDataSource (DB-first strategy)', () => { + let harness: ElectronXtreamDataSourceHarness; + + const playlistId = 'playlist-1'; + const dbCategory = { + id: 1, + name: 'News', + playlist_id: playlistId, + type: 'live' as const, + xtream_id: 10, + hidden: false, + }; + const dbContentItem = { + id: 1, + title: 'News Live', + xtream_id: 101, + type: 'live', + }; + + beforeEach(() => { + harness = setupElectronXtreamDataSource(); + }); + + describe('getCategories', () => { + it('returns cached categories without calling the API when import is completed', async () => { + harness.dbService.getXtreamImportStatus.mockResolvedValue( + 'completed' + ); + harness.dbService.getXtreamCategories.mockResolvedValue([ + dbCategory, + ]); + + const result = await harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ); + + expect(result).toEqual([dbCategory]); + expect(harness.apiService.getCategories).not.toHaveBeenCalled(); + expect(harness.dbService.getXtreamCategories).toHaveBeenCalledWith( + playlistId, + 'live' + ); + }); + + it('fetches from the API and caches to DB when the cache is cold', async () => { + const remoteCategories = [ + { category_id: '10', category_name: 'News' }, + ]; + harness.dbService.getXtreamCategories + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([dbCategory]); + harness.apiService.getCategories.mockResolvedValue( + remoteCategories + ); + const onPhaseChange = jest.fn(); + + const result = await harness.dataSource.getCategories( + playlistId, + credentials, + 'vod', + { sessionId: 'session-1', onPhaseChange } + ); + + expect(harness.apiService.getCategories).toHaveBeenCalledWith( + credentials, + 'vod', + { sessionId: 'session-1' } + ); + expect(harness.dbService.saveXtreamCategories).toHaveBeenCalledWith( + playlistId, + remoteCategories, + 'movies', + undefined + ); + expect(result).toEqual([dbCategory]); + expect(onPhaseChange.mock.calls).toEqual([ + ['loading-categories'], + ['saving-categories'], + ]); + }); + + it('refetches from the API when cached rows exist but the import never completed', async () => { + harness.dbService.getXtreamImportStatus.mockResolvedValue( + 'importing' + ); + harness.dbService.getXtreamCategories.mockResolvedValue([ + dbCategory, + ]); + harness.apiService.getCategories.mockResolvedValue([]); + + await harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ); + + expect(harness.apiService.getCategories).toHaveBeenCalledTimes(1); + }); + + it('skips caching when the API returns no categories', async () => { + harness.apiService.getCategories.mockResolvedValue([]); + + const result = await harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ); + + expect( + harness.dbService.saveXtreamCategories + ).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('restores hidden category visibility from the pending restore state', async () => { + harness.pendingRestoreService.get.mockReturnValue({ + hiddenCategories: [ + { categoryType: 'live', xtreamId: 5 }, + { categoryType: 'movies', xtreamId: 7 }, + ], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + }); + harness.apiService.getCategories.mockResolvedValue([ + { category_id: '5', category_name: 'Hidden' }, + ]); + + await harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ); + + expect(harness.pendingRestoreService.get).toHaveBeenCalledWith( + playlistId + ); + expect(harness.dbService.saveXtreamCategories).toHaveBeenCalledWith( + playlistId, + expect.any(Array), + 'live', + [5] + ); + }); + + it('deduplicates concurrent requests for the same playlist and type', async () => { + harness.apiService.getCategories.mockResolvedValue([ + { category_id: '10', category_name: 'News' }, + ]); + + const [first, second] = await Promise.all([ + harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ), + harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ), + ]); + + expect(first).toBe(second); + expect(harness.apiService.getCategories).toHaveBeenCalledTimes(1); + }); + + it('propagates API errors and allows a retry to reach the API again', async () => { + const failure = new Error('portal unreachable'); + harness.apiService.getCategories + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce([]); + + await expect( + harness.dataSource.getCategories(playlistId, credentials, 'live') + ).rejects.toThrow('portal unreachable'); + + await harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ); + expect(harness.apiService.getCategories).toHaveBeenCalledTimes(2); + }); + }); + + describe('getContent', () => { + it('returns cached content without calling the API when import is completed', async () => { + harness.dbService.getXtreamImportStatus.mockResolvedValue( + 'completed' + ); + harness.dbService.getXtreamContent.mockResolvedValue([ + dbContentItem, + ]); + + const result = await harness.dataSource.getContent( + playlistId, + credentials, + 'live' + ); + + expect(result).toEqual([dbContentItem]); + expect(harness.apiService.getStreams).not.toHaveBeenCalled(); + }); + + it('fetches from the API, reports progress, and caches on a cold cache', async () => { + const remoteStreams = [ + { stream_id: 101, name: 'News Live' }, + { stream_id: 102, name: 'Sports Live' }, + ]; + harness.dbService.getXtreamContent + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([dbContentItem]); + harness.apiService.getStreams.mockResolvedValue(remoteStreams); + const onProgress = jest.fn(); + const onTotal = jest.fn(); + const options = { sessionId: 'session-1' }; + + const result = await harness.dataSource.getContent( + playlistId, + credentials, + 'live', + onProgress, + onTotal, + options + ); + + expect(harness.apiService.getStreams).toHaveBeenCalledWith( + credentials, + 'live', + { sessionId: 'session-1' } + ); + expect(onTotal).toHaveBeenCalledWith(2); + expect(harness.dbService.saveXtreamContent).toHaveBeenCalledWith( + playlistId, + remoteStreams, + 'live', + onProgress, + options + ); + expect(result).toEqual([dbContentItem]); + }); + + it.each([ + ['live', 'loading-live'], + ['movie', 'loading-movies'], + ['series', 'loading-series'], + ] as const)( + 'reports the %s loading phase on a cold cache', + async (type, phase) => { + const onPhaseChange = jest.fn(); + + await harness.dataSource.getContent( + playlistId, + credentials, + type, + undefined, + undefined, + { onPhaseChange } + ); + + expect(onPhaseChange).toHaveBeenCalledWith(phase); + } + ); + + it('skips caching and progress reporting when the API returns nothing', async () => { + harness.apiService.getStreams.mockResolvedValue([]); + const onTotal = jest.fn(); + + const result = await harness.dataSource.getContent( + playlistId, + credentials, + 'movie', + undefined, + onTotal + ); + + expect(harness.dbService.saveXtreamContent).not.toHaveBeenCalled(); + expect(onTotal).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('deduplicates concurrent requests per type but not across types', async () => { + harness.apiService.getStreams.mockResolvedValue([ + { stream_id: 101, name: 'News Live' }, + ]); + + await Promise.all([ + harness.dataSource.getContent(playlistId, credentials, 'live'), + harness.dataSource.getContent(playlistId, credentials, 'live'), + harness.dataSource.getContent(playlistId, credentials, 'movie'), + ]); + + expect(harness.apiService.getStreams).toHaveBeenCalledTimes(2); + expect(harness.apiService.getStreams).toHaveBeenCalledWith( + credentials, + 'live', + { sessionId: undefined } + ); + expect(harness.apiService.getStreams).toHaveBeenCalledWith( + credentials, + 'movie', + { sessionId: undefined } + ); + }); + + it('propagates API errors and clears the in-flight request for retries', async () => { + const failure = new Error('stream fetch failed'); + harness.apiService.getStreams + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce([]); + + await expect( + harness.dataSource.getContent(playlistId, credentials, 'series') + ).rejects.toThrow('stream fetch failed'); + + await harness.dataSource.getContent( + playlistId, + credentials, + 'series' + ); + expect(harness.apiService.getStreams).toHaveBeenCalledTimes(2); + }); + + it('propagates DB errors from the import status check', async () => { + harness.dbService.getXtreamImportStatus.mockRejectedValue( + new Error('db locked') + ); + + await expect( + harness.dataSource.getContent(playlistId, credentials, 'live') + ).rejects.toThrow('db locked'); + expect(harness.apiService.getStreams).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts new file mode 100644 index 000000000..48adfd92d --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -0,0 +1,118 @@ +import { TestBed } from '@angular/core/testing'; +import { + DatabaseService, + PlaybackPositionService, + XtreamPendingRestoreService, +} from '@iptvnator/services'; +import { + XtreamApiService, + XtreamCredentials, +} from '../services/xtream-api.service'; +import { ElectronXtreamDataSource } from './electron-xtream-data-source'; + +/** + * Shared test setup for ElectronXtreamDataSource specs. + * Mocks the full DataService/IPC boundary — no real DB is touched. + */ + +export const credentials: XtreamCredentials = { + serverUrl: 'http://localhost:3211', + username: 'demo', + password: 'secret', +}; + +export function createDbServiceMock() { + return { + getXtreamImportStatus: jest.fn().mockResolvedValue('idle'), + getPlaylistById: jest.fn().mockResolvedValue(null), + createPlaylist: jest.fn().mockResolvedValue(undefined), + updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined), + deletePlaylist: jest.fn().mockResolvedValue(undefined), + hasXtreamCategories: jest.fn().mockResolvedValue(false), + getXtreamCategories: jest.fn().mockResolvedValue([]), + saveXtreamCategories: jest.fn().mockResolvedValue(undefined), + getAllXtreamCategories: jest.fn().mockResolvedValue([]), + updateCategoryVisibility: jest.fn().mockResolvedValue(undefined), + hasXtreamContent: jest.fn().mockResolvedValue(false), + getXtreamContent: jest.fn().mockResolvedValue([]), + saveXtreamContent: jest.fn().mockResolvedValue(0), + searchXtreamContent: jest.fn().mockResolvedValue([]), + getFavorites: jest.fn().mockResolvedValue([]), + addToFavorites: jest.fn().mockResolvedValue(undefined), + removeFromFavorites: jest.fn().mockResolvedValue(undefined), + isFavorite: jest.fn().mockResolvedValue(false), + getRecentItems: jest.fn().mockResolvedValue([]), + addRecentItem: jest.fn().mockResolvedValue(undefined), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn().mockResolvedValue(null), + setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), + deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({ + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + }), + restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), + }; +} + +export function createPlaybackServiceMock() { + return { + savePlaybackPosition: jest.fn().mockResolvedValue(undefined), + getPlaybackPosition: jest.fn().mockResolvedValue(null), + getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]), + getRecentPlaybackPositions: jest.fn().mockResolvedValue([]), + getAllPlaybackPositions: jest.fn().mockResolvedValue([]), + clearPlaybackPosition: jest.fn().mockResolvedValue(undefined), + clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), + }; +} + +export function createPendingRestoreServiceMock() { + return { + get: jest.fn().mockReturnValue(null), + }; +} + +export function createApiServiceMock() { + return { + getCategories: jest.fn().mockResolvedValue([]), + getStreams: jest.fn().mockResolvedValue([]), + }; +} + +export interface ElectronXtreamDataSourceHarness { + dataSource: ElectronXtreamDataSource; + dbService: ReturnType; + playbackService: ReturnType; + pendingRestoreService: ReturnType; + apiService: ReturnType; +} + +export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness { + const dbService = createDbServiceMock(); + const playbackService = createPlaybackServiceMock(); + const pendingRestoreService = createPendingRestoreServiceMock(); + const apiService = createApiServiceMock(); + + TestBed.configureTestingModule({ + providers: [ + ElectronXtreamDataSource, + { provide: DatabaseService, useValue: dbService }, + { provide: PlaybackPositionService, useValue: playbackService }, + { + provide: XtreamPendingRestoreService, + useValue: pendingRestoreService, + }, + { provide: XtreamApiService, useValue: apiService }, + ], + }); + + return { + dataSource: TestBed.inject(ElectronXtreamDataSource), + dbService, + playbackService, + pendingRestoreService, + apiService, + }; +} diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts new file mode 100644 index 000000000..1b887d1b4 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -0,0 +1,236 @@ +import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + ElectronXtreamDataSourceHarness, + setupElectronXtreamDataSource, +} from './electron-xtream-data-source.test-helpers'; + +/** + * Delegation tests for ElectronXtreamDataSource user data: favorites, + * recently viewed, playback positions, and cleanup/restore flows. + * Playlist/category/content delegation is covered in + * electron-xtream-data-source.delegation.spec.ts and the DB-first + * fetch/cache strategy in electron-xtream-data-source.spec.ts. + */ +describe('ElectronXtreamDataSource (user data delegation)', () => { + let harness: ElectronXtreamDataSourceHarness; + + const playlistId = 'playlist-1'; + + beforeEach(() => { + harness = setupElectronXtreamDataSource(); + }); + + describe('favorites and recently viewed', () => { + it('delegates favorites operations to the DB', async () => { + harness.dbService.isFavorite.mockResolvedValue(true); + + await harness.dataSource.addFavorite( + 202, + playlistId, + 'https://example.com/backdrop.png' + ); + expect(harness.dbService.addToFavorites).toHaveBeenCalledWith( + 202, + playlistId, + 'https://example.com/backdrop.png' + ); + + await harness.dataSource.removeFavorite(202, playlistId); + expect(harness.dbService.removeFromFavorites).toHaveBeenCalledWith( + 202, + playlistId + ); + + await expect( + harness.dataSource.isFavorite(202, playlistId) + ).resolves.toBe(true); + await harness.dataSource.getFavorites(playlistId); + expect(harness.dbService.getFavorites).toHaveBeenCalledWith( + playlistId + ); + }); + + it('delegates recently viewed operations to the DB', async () => { + await harness.dataSource.addRecentItem(202, playlistId); + expect(harness.dbService.addRecentItem).toHaveBeenCalledWith( + 202, + playlistId, + undefined + ); + + await harness.dataSource.removeRecentItem(202, playlistId); + expect(harness.dbService.removeRecentItem).toHaveBeenCalledWith( + 202, + playlistId + ); + + await harness.dataSource.getRecentItems(playlistId); + expect(harness.dbService.getRecentItems).toHaveBeenCalledWith( + playlistId + ); + + await harness.dataSource.clearRecentItems(playlistId); + expect( + harness.dbService.clearPlaylistRecentItems + ).toHaveBeenCalledWith(playlistId); + }); + + it('delegates content lookup and backdrop backfill to the DB', async () => { + const item = { id: 1, title: 'Movie One', xtream_id: 202 }; + harness.dbService.getContentByXtreamId.mockResolvedValue(item); + + await expect( + harness.dataSource.getContentByXtreamId( + 202, + playlistId, + 'movie' + ) + ).resolves.toEqual(item); + expect(harness.dbService.getContentByXtreamId).toHaveBeenCalledWith( + 202, + playlistId, + 'movie' + ); + + await harness.dataSource.setContentBackdropIfMissing( + 1, + playlistId, + 'https://example.com/backdrop.png' + ); + // playlistId is intentionally not forwarded for the Electron DB call + expect( + harness.dbService.setContentBackdropIfMissing + ).toHaveBeenCalledWith(1, 'https://example.com/backdrop.png'); + }); + }); + + describe('playback positions', () => { + const position = { + contentXtreamId: 202, + contentType: 'vod', + position: 120, + duration: 3600, + } as unknown as PlaybackPositionData; + + it('delegates playback position operations to the playback service', async () => { + harness.playbackService.getPlaybackPosition.mockResolvedValue( + position + ); + harness.playbackService.getSeriesPlaybackPositions.mockResolvedValue( + [position] + ); + harness.playbackService.getRecentPlaybackPositions.mockResolvedValue( + [position] + ); + harness.playbackService.getAllPlaybackPositions.mockResolvedValue([ + position, + ]); + + await harness.dataSource.savePlaybackPosition(playlistId, position); + expect( + harness.playbackService.savePlaybackPosition + ).toHaveBeenCalledWith(playlistId, position); + + await expect( + harness.dataSource.getPlaybackPosition(playlistId, 202, 'vod') + ).resolves.toEqual(position); + await expect( + harness.dataSource.getSeriesPlaybackPositions(playlistId, 303) + ).resolves.toEqual([position]); + expect( + harness.playbackService.getSeriesPlaybackPositions + ).toHaveBeenCalledWith(playlistId, 303); + + await expect( + harness.dataSource.getRecentPlaybackPositions(playlistId, 5) + ).resolves.toEqual([position]); + expect( + harness.playbackService.getRecentPlaybackPositions + ).toHaveBeenCalledWith(playlistId, 5); + + await expect( + harness.dataSource.getAllPlaybackPositions(playlistId) + ).resolves.toEqual([position]); + + await harness.dataSource.clearPlaybackPosition( + playlistId, + 202, + 'vod' + ); + expect( + harness.playbackService.clearPlaybackPosition + ).toHaveBeenCalledWith(playlistId, 202, 'vod'); + }); + }); + + describe('cleanup operations', () => { + it('clearSessionCache is a no-op that touches no services', () => { + expect( + harness.dataSource.clearSessionCache(playlistId) + ).toBeUndefined(); + expect( + harness.dbService.deleteXtreamPlaylistContent + ).not.toHaveBeenCalled(); + }); + + it('combines DB restore data with playback positions on clearPlaylistContent', async () => { + const hidden = [{ categoryType: 'live', xtreamId: 5 }]; + const favorites = [{ xtreamId: 202, type: 'movie' }]; + const recentlyViewed = [{ xtreamId: 101, type: 'live' }]; + const position = { contentXtreamId: 202 } as never; + harness.dbService.deleteXtreamPlaylistContent.mockResolvedValue({ + hiddenCategories: hidden, + favorites, + recentlyViewed, + }); + harness.playbackService.getAllPlaybackPositions.mockResolvedValue([ + position, + ]); + + await expect( + harness.dataSource.clearPlaylistContent(playlistId) + ).resolves.toEqual({ + hiddenCategories: hidden, + favorites, + recentlyViewed, + playbackPositions: [position], + }); + }); + + it('restores user data, then resets and replays playback positions', async () => { + const positionA = { contentXtreamId: 1 } as never; + const positionB = { contentXtreamId: 2 } as never; + const restoreState = { + hiddenCategories: [], + favorites: [{ xtreamId: 202, type: 'movie' }], + recentlyViewed: [{ xtreamId: 101, type: 'live' }], + playbackPositions: [positionA, positionB], + } as never; + const options = { operationId: 'op-1' }; + + await harness.dataSource.restoreUserData( + playlistId, + restoreState, + options + ); + + expect( + harness.dbService.restoreXtreamUserData + ).toHaveBeenCalledWith( + playlistId, + [{ xtreamId: 202, type: 'movie' }], + [{ xtreamId: 101, type: 'live' }], + options + ); + expect( + harness.playbackService.clearAllPlaybackPositions + ).toHaveBeenCalledWith(playlistId); + expect( + harness.playbackService.savePlaybackPosition.mock.calls + ).toEqual([ + [playlistId, positionA], + [playlistId, positionB], + ]); + }); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts index 3580e67ec..16e2972bf 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts @@ -191,4 +191,60 @@ describe('withFavorites', () => { ); expect(store.isFavorite()).toBe(true); }); + + it('rejects toggles with invalid Xtream IDs or missing playlist IDs', async () => { + await expect( + store.toggleFavorite('not-a-number', 'playlist-1', 'movie') + ).resolves.toBe(false); + await expect(store.toggleFavorite(0, 'playlist-1', 'movie')).resolves.toBe( + false + ); + await expect(store.toggleFavorite(290, '', 'movie')).resolves.toBe( + false + ); + + expect(dataSource.getContentByXtreamId).not.toHaveBeenCalled(); + expect(dataSource.addFavorite).not.toHaveBeenCalled(); + }); + + it('does not toggle Electron favorites when the cached content is missing', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: {} as Window['electron'], + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await store.toggleFavorite(290, 'playlist-1', 'series'); + + expect(result).toBe(false); + expect(dataSource.addFavorite).not.toHaveBeenCalled(); + expect(dataSource.removeFavorite).not.toHaveBeenCalled(); + expect(store.isFavorite()).toBe(false); + }); + + it('resets favorite state when checking with invalid inputs', async () => { + patchState(store, { isFavorite: true }); + + await store.checkFavoriteStatus('not-a-number', 'playlist-1', 'movie'); + + expect(dataSource.getContentByXtreamId).not.toHaveBeenCalled(); + expect(dataSource.isFavorite).not.toHaveBeenCalled(); + expect(store.isFavorite()).toBe(false); + }); + + it('resets Electron favorite state when the cached content is missing', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: {} as Window['electron'], + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + patchState(store, { isFavorite: true }); + + await store.checkFavoriteStatus(290, 'playlist-1', 'series'); + + expect(dataSource.isFavorite).not.toHaveBeenCalled(); + expect(store.isFavorite()).toBe(false); + }); }); diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts new file mode 100644 index 000000000..07a0485fc --- /dev/null +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -0,0 +1,210 @@ +import { __databaseConnectionTestHooks } from './connection'; + +const { + columnMigrationStatements, + createTables, + createTableStatements, + indexMigrationStatements, + runMigrations, +} = __databaseConnectionTestHooks; + +type SqliteHandle = Parameters[0]; + +function compactSql(statement: string): string { + return statement.replace(/\s+/g, ' ').trim(); +} + +type StatementHandler = { + all?: (...args: unknown[]) => unknown[]; + get?: (...args: unknown[]) => unknown; + run?: jest.Mock; +}; + +type HandlerRule = [pattern: string, handler: StatementHandler]; + +function createSqliteMock( + rules: HandlerRule[], + exec: jest.Mock = jest.fn() +) { + const prepare = jest.fn((statement: string) => { + const compact = compactSql(statement); + const rule = rules.find(([pattern]) => compact.includes(pattern)); + + return { + all: jest.fn(() => []), + get: jest.fn(), + run: jest.fn(), + ...(rule?.[1] ?? {}), + }; + }); + const transaction = jest.fn( + (callback: (...args: unknown[]) => unknown) => callback + ); + + return { + exec, + prepare, + sqlite: { exec, prepare, transaction } as unknown as SqliteHandle, + transaction, + }; +} + +const completedMigrationStateRule: HandlerRule = [ + 'SELECT value FROM app_state', + { get: () => ({ value: 'done' }) }, +]; + +describe('createTables', () => { + it('executes every fresh-install statement against the connection in order', () => { + const { exec, sqlite } = createSqliteMock([]); + + createTables(sqlite); + + expect(exec.mock.calls.map(([statement]) => statement)).toEqual([ + ...createTableStatements, + ]); + }); +}); + +describe('runMigrations error tolerance', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('silently skips duplicate-column ALTER errors and still applies index migrations', () => { + const exec = jest.fn((statement: string) => { + if (compactSql(statement).startsWith('ALTER TABLE')) { + throw new Error('duplicate column name: hidden'); + } + }); + const { sqlite } = createSqliteMock( + [completedMigrationStateRule], + exec + ); + + runMigrations(sqlite); + + const executedStatements = exec.mock.calls.map(([statement]) => + compactSql(statement) + ); + + expect(executedStatements).toEqual([ + ...columnMigrationStatements.map(compactSql), + ...indexMigrationStatements.map(compactSql), + ]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns and continues when a migration fails for another reason', () => { + const failingStatement = compactSql(columnMigrationStatements[0]); + const exec = jest.fn((statement: string) => { + if (compactSql(statement) === failingStatement) { + throw new Error('disk I/O error'); + } + }); + const { sqlite } = createSqliteMock( + [completedMigrationStateRule], + exec + ); + + runMigrations(sqlite); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Migration failed (continuing)') + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('disk I/O error') + ); + expect(exec).toHaveBeenCalledTimes( + columnMigrationStatements.length + indexMigrationStatements.length + ); + }); +}); + +describe('runMigrations Xtream cache deduplication', () => { + it('re-points content to the canonical duplicate category before deleting the rest', () => { + const candidatesAll = jest.fn(() => [ + { id: 1, hidden: 0, contentCount: 10 }, + { id: 2, hidden: 1, contentCount: 0 }, + ]); + const updateContentRun = jest.fn(); + const deleteCategoryRun = jest.fn(); + const { sqlite } = createSqliteMock([ + completedMigrationStateRule, + [ + 'FROM categories GROUP BY playlist_id, type, xtream_id', + { all: () => [{ playlistId: 'p1', type: 'live', xtreamId: 5 }] }, + ], + ['LEFT JOIN content', { all: candidatesAll }], + [ + 'UPDATE content SET category_id = ? WHERE category_id = ?', + { run: updateContentRun }, + ], + ['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }], + ['FROM content GROUP BY category_id, type, xtream_id', { all: () => [] }], + ]); + + runMigrations(sqlite); + + expect(candidatesAll).toHaveBeenCalledWith('p1', 'live', 5); + expect(updateContentRun).toHaveBeenCalledTimes(1); + expect(updateContentRun).toHaveBeenCalledWith(1, 2); + expect(deleteCategoryRun).toHaveBeenCalledTimes(1); + expect(deleteCategoryRun).toHaveBeenCalledWith(2); + }); + + it('moves favorites and history to the canonical content row before deleting duplicates', () => { + const moveFavoritesRun = jest.fn(); + const deleteFavoritesRun = jest.fn(); + const moveRecentlyViewedRun = jest.fn(); + const deleteRecentlyViewedRun = jest.fn(); + const deleteContentRun = jest.fn(); + const { sqlite } = createSqliteMock([ + completedMigrationStateRule, + [ + 'FROM categories GROUP BY playlist_id, type, xtream_id', + { all: () => [] }, + ], + [ + 'FROM content GROUP BY category_id, type, xtream_id', + { + all: () => [ + { categoryId: 7, type: 'movie', xtreamId: 300 }, + ], + }, + ], + [ + 'SELECT id FROM content WHERE category_id = ?', + { all: () => [{ id: 10 }, { id: 11 }] }, + ], + ['INSERT INTO favorites', { run: moveFavoritesRun }], + [ + 'DELETE FROM favorites WHERE content_id = ?', + { run: deleteFavoritesRun }, + ], + ['INSERT INTO recently_viewed', { run: moveRecentlyViewedRun }], + [ + 'DELETE FROM recently_viewed WHERE content_id = ?', + { run: deleteRecentlyViewedRun }, + ], + ['DELETE FROM content WHERE id = ?', { run: deleteContentRun }], + ]); + + runMigrations(sqlite); + + expect(moveFavoritesRun).toHaveBeenCalledWith(10, 11); + expect(deleteFavoritesRun).toHaveBeenCalledWith(11); + expect(moveRecentlyViewedRun).toHaveBeenCalledWith(10, 11); + expect(deleteRecentlyViewedRun).toHaveBeenCalledWith(11); + expect(deleteContentRun).toHaveBeenCalledTimes(1); + expect(deleteContentRun).toHaveBeenCalledWith(11); + }); +}); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index e4227a73f..a044a7cb4 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -342,6 +342,7 @@ const INDEX_MIGRATION_STATEMENTS = [ ]; export const __databaseConnectionTestHooks = { + createTables, createTableStatements: CREATE_TABLE_STATEMENTS, columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS, indexMigrationStatements: INDEX_MIGRATION_STATEMENTS, diff --git a/libs/shared/database/src/lib/path-utils.spec.ts b/libs/shared/database/src/lib/path-utils.spec.ts new file mode 100644 index 000000000..63de203e1 --- /dev/null +++ b/libs/shared/database/src/lib/path-utils.spec.ts @@ -0,0 +1,87 @@ +const homedirMock = jest.fn(); + +jest.mock('os', () => ({ + ...jest.requireActual('os'), + homedir: () => homedirMock(), +})); + +import { existsSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + IPTVNATOR_E2E_DATA_DIR_ENV, + getElectronConfigDirectory, + getElectronUserDataPath, + getIptvnatorDataRoot, + getIptvnatorDatabaseDirectory, + getIptvnatorDatabasePath, +} from './path-utils'; + +describe('path-utils', () => { + let tempRoot: string; + let originalEnvValue: string | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-path-utils-')); + homedirMock.mockReturnValue(join(tempRoot, 'home')); + originalEnvValue = process.env[IPTVNATOR_E2E_DATA_DIR_ENV]; + delete process.env[IPTVNATOR_E2E_DATA_DIR_ENV]; + }); + + afterEach(() => { + if (originalEnvValue === undefined) { + delete process.env[IPTVNATOR_E2E_DATA_DIR_ENV]; + } else { + process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = originalEnvValue; + } + rmSync(tempRoot, { force: true, recursive: true }); + }); + + it('uses and creates the E2E data dir override when the env variable is set', () => { + const e2eDataDir = join(tempRoot, 'e2e-data'); + process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir; + + expect(getIptvnatorDataRoot()).toBe(e2eDataDir); + expect(existsSync(e2eDataDir)).toBe(true); + }); + + it('falls back to ~/.iptvnator when the env override is blank', () => { + process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = ' '; + + const expectedRoot = join(tempRoot, 'home', '.iptvnator'); + + expect(getIptvnatorDataRoot()).toBe(expectedRoot); + expect(existsSync(expectedRoot)).toBe(true); + }); + + it('places the databases directory and database file under the data root', () => { + const e2eDataDir = join(tempRoot, 'e2e-data'); + process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir; + + const databaseDirectory = getIptvnatorDatabaseDirectory(); + + expect(databaseDirectory).toBe(join(e2eDataDir, 'databases')); + expect(existsSync(databaseDirectory)).toBe(true); + expect(getIptvnatorDatabasePath()).toBe( + join(e2eDataDir, 'databases', 'iptvnator.db') + ); + }); + + it('returns null Electron user-data and config paths outside E2E runs', () => { + expect(getElectronUserDataPath()).toBeNull(); + expect(getElectronConfigDirectory()).toBeNull(); + }); + + it('creates Electron user-data and config directories under the E2E root', () => { + const e2eDataDir = join(tempRoot, 'e2e-data'); + process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir; + + const userDataPath = getElectronUserDataPath(); + const configDirectory = getElectronConfigDirectory(); + + expect(userDataPath).toBe(join(e2eDataDir, 'user-data')); + expect(configDirectory).toBe(join(e2eDataDir, 'config')); + expect(existsSync(userDataPath as string)).toBe(true); + expect(existsSync(configDirectory as string)).toBe(true); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts new file mode 100644 index 000000000..08fd1e6d9 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts @@ -0,0 +1,110 @@ +import { + canCatchUpProgramme, + epgDialogActionFor, + isWithinArchiveWindow, +} from './epg-archive.util'; + +const DAY_MS = 24 * 60 * 60_000; +const NOW = new Date(2026, 5, 28, 12, 0, 0, 0).getTime(); // 28 Jun 2026, 12:00 local + +describe('epg-archive.util', () => { + describe('isWithinArchiveWindow', () => { + it('treats archiveDays = 0 as an unlimited window', () => { + // capability flagged but no explicit window → everything is in range + expect(isWithinArchiveWindow(NOW - 365 * DAY_MS, 0, NOW)).toBe( + true + ); + expect(isWithinArchiveWindow(0, 0, NOW)).toBe(true); + }); + + it('treats negative archiveDays as unlimited too', () => { + expect(isWithinArchiveWindow(NOW - 365 * DAY_MS, -1, NOW)).toBe( + true + ); + }); + + it('rejects everything when archiveDays is undefined (NaN window)', () => { + // undefined is not <= 0, and NaN comparisons are always false + const archiveDays = undefined as unknown as number; + expect(isWithinArchiveWindow(NOW, archiveDays, NOW)).toBe(false); + }); + + it('accepts a start exactly on the window edge (inclusive)', () => { + const edge = NOW - 7 * DAY_MS; + expect(isWithinArchiveWindow(edge, 7, NOW)).toBe(true); + }); + + it('accepts starts inside the window', () => { + expect(isWithinArchiveWindow(NOW - 3 * DAY_MS, 7, NOW)).toBe(true); + expect(isWithinArchiveWindow(NOW - 1, 7, NOW)).toBe(true); + }); + + it('rejects starts just outside the window (expired)', () => { + expect(isWithinArchiveWindow(NOW - 7 * DAY_MS - 1, 7, NOW)).toBe( + false + ); + expect(isWithinArchiveWindow(NOW - 30 * DAY_MS, 7, NOW)).toBe( + false + ); + }); + }); + + describe('canCatchUpProgramme', () => { + const inWindowStart = NOW - 2 * 60 * 60_000; // 2h ago, well inside 7 days + + it('allows catch-up when every gate passes', () => { + expect( + canCatchUpProgramme('past', inWindowStart, true, 7, NOW) + ).toBe(true); + }); + + it('denies when archive playback is unavailable', () => { + expect( + canCatchUpProgramme('past', inWindowStart, false, 7, NOW) + ).toBe(false); + }); + + it('denies when the programme is not in the past', () => { + expect( + canCatchUpProgramme('now', inWindowStart, true, 7, NOW) + ).toBe(false); + expect( + canCatchUpProgramme('future', NOW + 60_000, true, 7, NOW) + ).toBe(false); + }); + + it('denies when the start is outside the archive window', () => { + expect( + canCatchUpProgramme('past', NOW - 8 * DAY_MS, true, 7, NOW) + ).toBe(false); + }); + + it('allows arbitrarily old past programmes with an unlimited window', () => { + expect( + canCatchUpProgramme('past', NOW - 100 * DAY_MS, true, 0, NOW) + ).toBe(true); + }); + }); + + describe('epgDialogActionFor', () => { + it('maps an on-air programme to the live action', () => { + expect(epgDialogActionFor('now', false)).toBe('live'); + }); + + it('prefers live over timeshift for an on-air programme', () => { + expect(epgDialogActionFor('now', true)).toBe('live'); + }); + + it('maps a catch-up-able past programme to timeshift', () => { + expect(epgDialogActionFor('past', true)).toBe('timeshift'); + }); + + it('offers no action for a past programme without catch-up', () => { + expect(epgDialogActionFor('past', false)).toBeNull(); + }); + + it('offers no action for a future programme', () => { + expect(epgDialogActionFor('future', false)).toBeNull(); + }); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts new file mode 100644 index 000000000..3ab036664 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts @@ -0,0 +1,192 @@ +import { + EpgTimelineSummary, + formatClockTime, + summaryHasTimeRange, + summaryHasTitle, + summaryMinutesLeft, + summaryProgress, +} from './epg-summary.util'; + +const HOUR_MS = 60 * 60_000; +const NOW = new Date(2026, 5, 28, 12, 0, 0, 0).getTime(); // 28 Jun 2026, 12:00 local +const START = NOW - HOUR_MS; // 11:00 +const STOP = NOW + HOUR_MS; // 13:00 + +function summary(overrides: EpgTimelineSummary = {}): EpgTimelineSummary { + return { title: 'Show', start: START, stop: STOP, ...overrides }; +} + +describe('epg-summary.util', () => { + describe('summaryProgress', () => { + it('returns null for a missing summary', () => { + expect(summaryProgress(null, NOW)).toBeNull(); + expect(summaryProgress(undefined, NOW)).toBeNull(); + }); + + it('computes elapsed percentage between start and stop', () => { + // 11:00 → 13:00, now 12:00 → halfway + expect(summaryProgress(summary(), NOW)).toBe(50); + expect(summaryProgress(summary(), START + HOUR_MS / 2)).toBe(25); + }); + + it('returns 0 at the exact start and 100 at the exact stop', () => { + expect(summaryProgress(summary(), START)).toBe(0); + expect(summaryProgress(summary(), STOP)).toBe(100); + }); + + it('clamps to 0 before the start and 100 after the stop', () => { + expect(summaryProgress(summary(), START - HOUR_MS)).toBe(0); + expect(summaryProgress(summary(), STOP + HOUR_MS)).toBe(100); + }); + + it('returns null when start or stop is missing', () => { + expect(summaryProgress(summary({ start: null }), NOW)).toBeNull(); + expect( + summaryProgress(summary({ stop: undefined }), NOW) + ).toBeNull(); + expect(summaryProgress(summary({ start: '' }), NOW)).toBeNull(); + }); + + it('returns null for unparsable dates', () => { + expect( + summaryProgress(summary({ start: 'not-a-date' }), NOW) + ).toBeNull(); + expect( + summaryProgress(summary({ stop: new Date(NaN) }), NOW) + ).toBeNull(); + }); + + it('returns null for zero or negative duration', () => { + expect( + summaryProgress(summary({ start: NOW, stop: NOW }), NOW) + ).toBeNull(); + expect( + summaryProgress(summary({ start: STOP, stop: START }), NOW) + ).toBeNull(); + }); + + it('accepts ISO strings and Date objects as time inputs', () => { + const iso = summary({ + start: new Date(START).toISOString(), + stop: new Date(STOP), + }); + expect(summaryProgress(iso, NOW)).toBe(50); + }); + + it('prefers an explicit progress value over the time range', () => { + expect(summaryProgress(summary({ progress: 42 }), NOW)).toBe(42); + }); + + it('clamps an explicit progress value into 0–100', () => { + expect(summaryProgress(summary({ progress: 150 }), NOW)).toBe(100); + expect(summaryProgress(summary({ progress: -5 }), NOW)).toBe(0); + }); + + it('coerces an explicit null progress to 0 (Number(null) is finite)', () => { + expect(summaryProgress(summary({ progress: null }), NOW)).toBe(0); + }); + + it('falls back to the time range when progress is undefined', () => { + expect(summaryProgress(summary({ progress: undefined }), NOW)).toBe( + 50 + ); + }); + }); + + describe('summaryMinutesLeft', () => { + it('returns null without a summary or stop time', () => { + expect(summaryMinutesLeft(null, NOW)).toBeNull(); + expect(summaryMinutesLeft(undefined, NOW)).toBeNull(); + expect( + summaryMinutesLeft(summary({ stop: null }), NOW) + ).toBeNull(); + expect( + summaryMinutesLeft(summary({ stop: 'garbage' }), NOW) + ).toBeNull(); + }); + + it('returns whole minutes until the stop', () => { + expect(summaryMinutesLeft(summary(), NOW)).toBe(60); + }); + + it('rounds to the nearest minute', () => { + expect( + summaryMinutesLeft(summary({ stop: NOW + 90_000 }), NOW) + ).toBe(2); // 1.5 min rounds up + expect( + summaryMinutesLeft(summary({ stop: NOW + 89_000 }), NOW) + ).toBe(1); // ~1.48 min rounds down + }); + + it('never goes below zero once the programme ended', () => { + expect( + summaryMinutesLeft(summary({ stop: NOW - HOUR_MS }), NOW) + ).toBe(0); + }); + + it('returns 0 exactly at the stop', () => { + expect(summaryMinutesLeft(summary({ stop: NOW }), NOW)).toBe(0); + }); + }); + + describe('summaryHasTitle', () => { + it('is false for missing summaries and titles', () => { + expect(summaryHasTitle(null)).toBe(false); + expect(summaryHasTitle(undefined)).toBe(false); + expect(summaryHasTitle(summary({ title: null }))).toBe(false); + expect(summaryHasTitle(summary({ title: undefined }))).toBe(false); + }); + + it('is false for blank titles', () => { + expect(summaryHasTitle(summary({ title: '' }))).toBe(false); + expect(summaryHasTitle(summary({ title: ' ' }))).toBe(false); + }); + + it('is true for a non-blank title', () => { + expect(summaryHasTitle(summary({ title: 'News' }))).toBe(true); + }); + }); + + describe('summaryHasTimeRange', () => { + it('is false without a summary or any time', () => { + expect(summaryHasTimeRange(null)).toBe(false); + expect(summaryHasTimeRange(undefined)).toBe(false); + expect( + summaryHasTimeRange({ title: 'x', start: null, stop: null }) + ).toBe(false); + }); + + it('is true when either start or stop is set', () => { + expect( + summaryHasTimeRange({ start: START, stop: null }) + ).toBe(true); + expect(summaryHasTimeRange({ start: null, stop: STOP })).toBe( + true + ); + }); + + it('treats a numeric 0 timestamp as absent (truthiness check)', () => { + expect(summaryHasTimeRange({ start: 0, stop: null })).toBe(false); + }); + }); + + describe('formatClockTime', () => { + it('formats a local HH:MM label', () => { + expect( + formatClockTime(new Date(2026, 5, 28, 12, 34).getTime()) + ).toBe('12:34'); + }); + + it('zero-pads single-digit hours and minutes', () => { + expect( + formatClockTime(new Date(2026, 5, 28, 9, 5).getTime()) + ).toBe('09:05'); + }); + + it('renders midnight as 00:00', () => { + expect( + formatClockTime(new Date(2026, 5, 28, 0, 0).getTime()) + ).toBe('00:00'); + }); + }); +});