From 96568fc151bfc431191998eb8f0834528e19aad5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:11 +0200 Subject: [PATCH 1/9] test(db): cover playback positions, recently viewed, and connection migrations Add specs for the previously untested persistence paths: playback-position and recently-viewed operations (upsert/dedup/ordering/scoped deletes), shared-database path-utils, createTables and the tolerant column/index migrations incl. Xtream cache deduplication. Exposes createTables through the existing __databaseConnectionTestHooks object. Co-Authored-By: Claude Fable 5 --- .../playback-position.operations.spec.ts | 358 ++++++++++++++++++ .../recently-viewed.operations.spec.ts | 351 +++++++++++++++++ .../src/lib/connection-migrations.spec.ts | 210 ++++++++++ libs/shared/database/src/lib/connection.ts | 1 + .../database/src/lib/path-utils.spec.ts | 87 +++++ 5 files changed, 1007 insertions(+) create mode 100644 apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts create mode 100644 libs/shared/database/src/lib/connection-migrations.spec.ts create mode 100644 libs/shared/database/src/lib/path-utils.spec.ts 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..88d05832b --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts @@ -0,0 +1,358 @@ +const andMock = jest.fn((...conditions: unknown[]) => ({ + kind: 'and', + conditions, +})); +const eqMock = jest.fn((left: unknown, right: unknown) => ({ + kind: 'eq', + left, + right, +})); +const descMock = jest.fn((value: unknown) => ({ kind: 'desc', value })); +const sqlMock = jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings: Array.from(strings), + values, +})); + +jest.mock('drizzle-orm', () => ({ + and: (...conditions: unknown[]) => andMock(...conditions), + desc: (value: unknown) => descMock(value), + eq: (left: unknown, right: unknown) => eqMock(left, right), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => + sqlMock(strings, ...values), +})); + +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { + clearAllPlaybackPositions, + clearPlaybackPosition, + getAllPlaybackPositions, + getPlaybackPosition, + getRecentPlaybackPositions, + getSeriesPlaybackPositions, + savePlaybackPosition, +} from './playback-position.operations'; + +type QueryMock = { + from: jest.Mock; + where: jest.Mock; + orderBy: jest.Mock; + limit: jest.Mock; + then: ( + resolve: (value: unknown[]) => void, + reject: (reason: unknown) => void + ) => Promise; +}; + +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(), + 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.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 deleteWhere = jest.fn().mockResolvedValue(undefined); + const deleteFn = jest.fn().mockReturnValue({ where: deleteWhere }); + + return { + db: { + select, + insert, + update, + delete: deleteFn, + } as unknown as AppDatabase, + deleteFn, + deleteWhere, + insert, + insertValues, + queries, + select, + update, + updateSet, + updateWhere, + }; +} + +describe('playback-position.operations', () => { + beforeEach(() => { + andMock.mockClear(); + descMock.mockClear(); + eqMock.mockClear(); + sqlMock.mockClear(); + }); + + 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(eqMock).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(sqlMock).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(eqMock).toHaveBeenCalledWith( + schema.playbackPositions.playlistId, + 'playlist-1' + ); + expect(eqMock).toHaveBeenCalledWith( + schema.playbackPositions.contentXtreamId, + 500 + ); + expect(eqMock).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(eqMock).toHaveBeenCalledWith( + schema.playbackPositions.seriesXtreamId, + 42 + ); + expect(eqMock).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(descMock).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(eqMock).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(eqMock).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(eqMock).toHaveBeenCalledWith( + schema.playbackPositions.contentXtreamId, + 500 + ); + expect(eqMock).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..6bd4db201 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -0,0 +1,351 @@ +const andMock = jest.fn((...conditions: unknown[]) => ({ + kind: 'and', + conditions, +})); +const eqMock = jest.fn((left: unknown, right: unknown) => ({ + kind: 'eq', + left, + right, +})); +const descMock = jest.fn((value: unknown) => ({ kind: 'desc', value })); +const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({ + kind: 'inArray', + left, + values, +})); +const sqlMock = Object.assign( + jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings: Array.from(strings), + values, + })), + { + placeholder: jest.fn((name: string) => ({ + kind: 'placeholder', + name, + })), + } +); + +jest.mock('drizzle-orm', () => ({ + and: (...conditions: unknown[]) => andMock(...conditions), + desc: (value: unknown) => descMock(value), + eq: (left: unknown, right: unknown) => eqMock(left, right), + inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values), + sql: sqlMock, +})); + +jest.mock('./content-backdrop.operations', () => ({ + persistContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), +})); + +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { persistContentBackdropIfMissing } from './content-backdrop.operations'; +import { + addRecentItem, + clearPlaylistRecentItems, + clearRecentlyViewed, + getRecentItems, + getRecentlyViewed, + removeRecentItem, + removeRecentItemsBatch, +} from './recently-viewed.operations'; + +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; +}; + +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, + }; +} + +describe('recently-viewed.operations', () => { + beforeEach(() => { + andMock.mockClear(); + descMock.mockClear(); + eqMock.mockClear(); + inArrayMock.mockClear(); + sqlMock.mockClear(); + sqlMock.placeholder.mockClear(); + (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(descMock).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(eqMock).toHaveBeenCalledWith( + schema.recentlyViewed.playlistId, + 'playlist-1' + ); + expect(descMock).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(sqlMock).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']); + expect(eqMock).toHaveBeenCalledWith( + schema.recentlyViewed.contentId, + 42 + ); + expect(eqMock).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(eqMock).toHaveBeenCalledWith( + schema.categories.playlistId, + 'playlist-1' + ); + expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed); + expect(inArrayMock).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(eqMock).toHaveBeenCalledWith( + schema.recentlyViewed.contentId, + 42 + ); + expect(eqMock).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(sqlMock.placeholder).toHaveBeenCalledWith('contentId'); + expect(sqlMock.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/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); + }); +}); From e3fcebe9faaf9191a9a3637545d7b92dbb1a66f1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:35 +0200 Subject: [PATCH 2/9] test(xtream): cover the Electron DB-first data source and favorites guards Add specs for electron-xtream-data-source (DB-hit vs cold-cache paths, concurrent request dedup, error propagation, full method delegation) and extend the favorites feature spec with the invalid-input and content-not-found guard paths. Co-Authored-By: Claude Fable 5 --- ...tron-xtream-data-source.delegation.spec.ts | 391 ++++++++++++++++++ .../electron-xtream-data-source.spec.ts | 350 ++++++++++++++++ ...lectron-xtream-data-source.test-helpers.ts | 118 ++++++ .../src/lib/with-favorites.feature.spec.ts | 56 +++ 4 files changed, 915 insertions(+) create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts 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..79aa0f906 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts @@ -0,0 +1,391 @@ +import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + ElectronXtreamDataSourceHarness, + setupElectronXtreamDataSource, +} from './electron-xtream-data-source.test-helpers'; + +/** + * Delegation tests for ElectronXtreamDataSource: methods that forward to + * DatabaseService / PlaybackPositionService over the IPC boundary. + * The DB-first fetch/cache strategy is covered 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 + ); + }); + }); + + 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/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/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); + }); }); From 463ddd077f70b9386274d04df3cf233e4fda7c3c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:35 +0200 Subject: [PATCH 3/9] test(stalker): cover favorites and recent store features Add specs for with-stalker-favorites and with-stalker-recent: payload normalization and id/title fallbacks, series-mode category forcing, meta sync dispatches, snackbar/callback side effects, and error paths. Co-Authored-By: Claude Fable 5 --- .../with-stalker-favorites.feature.spec.ts | 192 ++++++++++++++++ .../with-stalker-recent.feature.spec.ts | 209 ++++++++++++++++++ 2 files changed, 401 insertions(+) create mode 100644 libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-favorites.feature.spec.ts create mode 100644 libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-recent.feature.spec.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(); + }); + }); +}); From 1761fdffb856149e639c8e25563a7343740f0cc2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:35 +0200 Subject: [PATCH 4/9] test(epg): cover archive/summary utils and the EPG worker service Add specs for the pure catch-up window and summary-progress helpers shared by the EPG panels, and for epg-worker.service: in-flight dedup by URL, double-settle guard, progress-aware timeouts, worker lifecycle and error broadcasting. Also settle an interrupted fetch in epg.events.spec that caused "Cannot log after tests are done" in longer runs. Co-Authored-By: Claude Fable 5 --- .../epg-worker.service.progress.spec.ts | 237 ++++++++++++++++++ .../src/app/events/epg-worker.service.spec.ts | 221 ++++++++++++++++ .../src/app/events/epg.events.spec.ts | 5 + .../lib/epg-timeline/epg-archive.util.spec.ts | 110 ++++++++ .../lib/epg-timeline/epg-summary.util.spec.ts | 192 ++++++++++++++ 5 files changed, 765 insertions(+) create mode 100644 apps/electron-backend/src/app/events/epg-worker.service.progress.spec.ts create mode 100644 apps/electron-backend/src/app/events/epg-worker.service.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-archive.util.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-summary.util.spec.ts 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/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'); + }); + }); +}); From be2730edfa7ca72cb7aa886fa156bb2e0c65d7ec Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:35 +0200 Subject: [PATCH 5/9] test(player): cover the VLC session service Mirror the MPV session spec patterns for VLC: enqueue-command building and RC response parsing, launch argv construction, instance reuse over the RC socket, exit-code handling, and the retry-without-RC fallback. Co-Authored-By: Claude Fable 5 --- .../vlc-session.service.lifecycle.spec.ts | 281 ++++++++++++++++++ .../app/events/vlc-session.service.spec.ts | 246 +++++++++++++++ 2 files changed, 527 insertions(+) create mode 100644 apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts create mode 100644 apps/electron-backend/src/app/events/vlc-session.service.spec.ts 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(); + }); + }); +}); From d2b95442ed1bf85f92cfcdf8d5004344e3dfbc99 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:35 +0200 Subject: [PATCH 6/9] test(e2e): add downloads page and EPG timeline interaction coverage Downloads: empty state without sources, and a full lifecycle - authorize a folder via a stubbed native dialog, download from a local server, verify the completed item and file on disk, remove it from the UI. Timeline: zoom changes block widths and the on-air info affordance opens the programme dialog with the correct title and watch-live action. Co-Authored-By: Claude Fable 5 --- .../electron-backend-e2e/src/downloads.e2e.ts | 137 ++++++++++++++++++ .../src/epg-timeline-interaction.e2e.ts | 101 +++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 apps/electron-backend-e2e/src/downloads.e2e.ts create mode 100644 apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts 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..498b5e4bf --- /dev/null +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -0,0 +1,137 @@ +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(?:\?.*)?$/); +} + +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 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); + } +}); From f8fb03ef1e3ddeee14c0942cf639565cc156864b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 12:04:10 +0200 Subject: [PATCH 7/9] test: split oversized specs to meet the file-size guideline Address review feedback: extract shared drizzle mocks into operations.test-helpers.ts and split the Electron data-source delegation spec into delegation + user-data files. Pure reorganization - test counts and assertions unchanged (29/13/10), all files now under 300 lines. Co-Authored-By: Claude Fable 5 --- .../operations/operations.test-helpers.ts | 165 ++++++++++++ .../playback-position.operations.spec.ts | 122 ++------- .../recently-viewed.operations.spec.ts | 165 ++---------- ...tron-xtream-data-source.delegation.spec.ts | 224 +---------------- ...ctron-xtream-data-source.user-data.spec.ts | 236 ++++++++++++++++++ 5 files changed, 448 insertions(+), 464 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/operations.test-helpers.ts create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts 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 index 88d05832b..1a919641d 100644 --- 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 @@ -1,29 +1,13 @@ -const andMock = jest.fn((...conditions: unknown[]) => ({ - kind: 'and', - conditions, -})); -const eqMock = jest.fn((left: unknown, right: unknown) => ({ - kind: 'eq', - left, - right, -})); -const descMock = jest.fn((value: unknown) => ({ kind: 'desc', value })); -const sqlMock = jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ - kind: 'sql', - strings: Array.from(strings), - values, -})); - -jest.mock('drizzle-orm', () => ({ - and: (...conditions: unknown[]) => andMock(...conditions), - desc: (value: unknown) => descMock(value), - eq: (left: unknown, right: unknown) => eqMock(left, right), - sql: (strings: TemplateStringsArray, ...values: unknown[]) => - sqlMock(strings, ...values), -})); +import { + createDbMock, + mockDrizzle, + mockDrizzleOrmModule, + resetDrizzleMocks, +} from './operations.test-helpers'; + +jest.mock('drizzle-orm', () => mockDrizzleOrmModule()); import * as schema from '@iptvnator/shared/database/schema'; -import type { AppDatabase } from '../database.types'; import { clearAllPlaybackPositions, clearPlaybackPosition, @@ -34,73 +18,9 @@ import { savePlaybackPosition, } from './playback-position.operations'; -type QueryMock = { - from: jest.Mock; - where: jest.Mock; - orderBy: jest.Mock; - limit: jest.Mock; - then: ( - resolve: (value: unknown[]) => void, - reject: (reason: unknown) => void - ) => Promise; -}; - -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(), - 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.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 deleteWhere = jest.fn().mockResolvedValue(undefined); - const deleteFn = jest.fn().mockReturnValue({ where: deleteWhere }); - - return { - db: { - select, - insert, - update, - delete: deleteFn, - } as unknown as AppDatabase, - deleteFn, - deleteWhere, - insert, - insertValues, - queries, - select, - update, - updateSet, - updateWhere, - }; -} - describe('playback-position.operations', () => { beforeEach(() => { - andMock.mockClear(); - descMock.mockClear(); - eqMock.mockClear(); - sqlMock.mockClear(); + resetDrizzleMocks(); }); describe('savePlaybackPosition', () => { @@ -193,7 +113,7 @@ describe('playback-position.operations', () => { positionSeconds: 480, }) ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.id, 33 ); @@ -208,7 +128,7 @@ describe('playback-position.operations', () => { positionSeconds: 120, }); - expect(sqlMock).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']); + expect(mockDrizzle.sql).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']); expect(insertValues).toHaveBeenNthCalledWith( 2, expect.objectContaining({ @@ -236,15 +156,15 @@ describe('playback-position.operations', () => { ); expect(result).toEqual(row); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.playlistId, 'playlist-1' ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.contentXtreamId, 500 ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.contentType, 'vod' ); @@ -268,11 +188,11 @@ describe('playback-position.operations', () => { getSeriesPlaybackPositions(db, 'playlist-1', 42) ).resolves.toEqual(rows); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.seriesXtreamId, 42 ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.contentType, 'episode' ); @@ -287,7 +207,7 @@ describe('playback-position.operations', () => { getRecentPlaybackPositions(db, 'playlist-1') ).resolves.toEqual(rows); - expect(descMock).toHaveBeenCalledWith( + expect(mockDrizzle.desc).toHaveBeenCalledWith( schema.playbackPositions.updatedAt ); expect(queries[0].limit).toHaveBeenCalledWith(20); @@ -309,7 +229,7 @@ describe('playback-position.operations', () => { getAllPlaybackPositions(db, 'playlist-1') ).resolves.toEqual(rows); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.playlistId, 'playlist-1' ); @@ -330,7 +250,7 @@ describe('playback-position.operations', () => { expect(deleteWhere).toHaveBeenCalledWith( expect.objectContaining({ kind: 'eq' }) ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.playlistId, 'playlist-1' ); @@ -345,11 +265,11 @@ describe('playback-position.operations', () => { expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions); expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(3); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.playbackPositions.contentXtreamId, 500 ); - expect(eqMock).toHaveBeenCalledWith( + 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 index 6bd4db201..a1a151a8c 100644 --- 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 @@ -1,46 +1,17 @@ -const andMock = jest.fn((...conditions: unknown[]) => ({ - kind: 'and', - conditions, -})); -const eqMock = jest.fn((left: unknown, right: unknown) => ({ - kind: 'eq', - left, - right, -})); -const descMock = jest.fn((value: unknown) => ({ kind: 'desc', value })); -const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({ - kind: 'inArray', - left, - values, -})); -const sqlMock = Object.assign( - jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ - kind: 'sql', - strings: Array.from(strings), - values, - })), - { - placeholder: jest.fn((name: string) => ({ - kind: 'placeholder', - name, - })), - } -); - -jest.mock('drizzle-orm', () => ({ - and: (...conditions: unknown[]) => andMock(...conditions), - desc: (value: unknown) => descMock(value), - eq: (left: unknown, right: unknown) => eqMock(left, right), - inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values), - sql: sqlMock, -})); +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 type { AppDatabase } from '../database.types'; import { persistContentBackdropIfMissing } from './content-backdrop.operations'; import { addRecentItem, @@ -52,103 +23,9 @@ import { removeRecentItemsBatch, } from './recently-viewed.operations'; -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; -}; - -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, - }; -} - describe('recently-viewed.operations', () => { beforeEach(() => { - andMock.mockClear(); - descMock.mockClear(); - eqMock.mockClear(); - inArrayMock.mockClear(); - sqlMock.mockClear(); - sqlMock.placeholder.mockClear(); + resetDrizzleMocks(); (persistContentBackdropIfMissing as jest.Mock).mockClear(); }); @@ -162,7 +39,7 @@ describe('recently-viewed.operations', () => { await expect(getRecentlyViewed(db)).resolves.toEqual(rows); - expect(descMock).toHaveBeenCalledWith( + expect(mockDrizzle.desc).toHaveBeenCalledWith( schema.recentlyViewed.viewedAt ); expect(queries[0].orderBy).toHaveBeenCalledWith( @@ -180,11 +57,11 @@ describe('recently-viewed.operations', () => { rows ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.recentlyViewed.playlistId, 'playlist-1' ); - expect(descMock).toHaveBeenCalledWith( + expect(mockDrizzle.desc).toHaveBeenCalledWith( schema.recentlyViewed.viewedAt ); expect(queries[0].limit).toHaveBeenCalledWith(100); @@ -228,12 +105,12 @@ describe('recently-viewed.operations', () => { expect(updateSet).toHaveBeenCalledWith({ viewedAt: expect.objectContaining({ kind: 'sql' }), }); - expect(sqlMock).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.sql).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']); + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.recentlyViewed.contentId, 42 ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.recentlyViewed.playlistId, 'playlist-1' ); @@ -266,12 +143,12 @@ describe('recently-viewed.operations', () => { clearPlaylistRecentItems(db, 'playlist-1') ).resolves.toEqual({ success: true }); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.categories.playlistId, 'playlist-1' ); expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed); - expect(inArrayMock).toHaveBeenCalledWith( + expect(mockDrizzle.inArray).toHaveBeenCalledWith( schema.recentlyViewed.contentId, [11, 12] ); @@ -299,11 +176,11 @@ describe('recently-viewed.operations', () => { expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed); expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(2); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.recentlyViewed.contentId, 42 ); - expect(eqMock).toHaveBeenCalledWith( + expect(mockDrizzle.eq).toHaveBeenCalledWith( schema.recentlyViewed.playlistId, 'playlist-1' ); @@ -335,8 +212,8 @@ describe('recently-viewed.operations', () => { ).resolves.toEqual({ success: true, count: 2 }); expect(deletePrepare).toHaveBeenCalledTimes(1); - expect(sqlMock.placeholder).toHaveBeenCalledWith('contentId'); - expect(sqlMock.placeholder).toHaveBeenCalledWith('playlistId'); + expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('contentId'); + expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('playlistId'); expect(transaction).toHaveBeenCalledTimes(1); expect(deleteExecute).toHaveBeenNthCalledWith(1, { contentId: 1, 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 index 79aa0f906..983a85c21 100644 --- 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 @@ -1,14 +1,14 @@ -import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { ElectronXtreamDataSourceHarness, setupElectronXtreamDataSource, } from './electron-xtream-data-source.test-helpers'; /** - * Delegation tests for ElectronXtreamDataSource: methods that forward to - * DatabaseService / PlaybackPositionService over the IPC boundary. - * The DB-first fetch/cache strategy is covered in - * electron-xtream-data-source.spec.ts. + * 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; @@ -174,218 +174,4 @@ describe('ElectronXtreamDataSource (delegation)', () => { ); }); }); - - 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/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], + ]); + }); + }); +}); From 0a85df41afbdcef62346def942c3795102a9d7b6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 12:04:10 +0200 Subject: [PATCH 8/9] test(e2e): wait for DB readiness before opening the downloads page On slow CI runners (macOS) the renderer can query SQLite while the DB worker is still creating tables, leaving the downloads page on its skeleton state forever. Poll a playlist read until it succeeds before navigating on a cold profile. Co-Authored-By: Claude Fable 5 --- .../electron-backend-e2e/src/downloads.e2e.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index 498b5e4bf..6810cc3c1 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -17,6 +17,34 @@ async function openDownloadsPage(page: Page): Promise { 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, @@ -24,6 +52,7 @@ test.describe('Electron Downloads', () => { const app = await launchElectronApp(dataDir); try { + await waitForDatabaseReady(app.mainWindow); await openDownloadsPage(app.mainWindow); await expect( From 7cc31ac526d9cedd81be2308f86f3e03b131a585 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 12:09:14 +0200 Subject: [PATCH 9/9] fix(build): exclude *.test-helpers.ts from the electron-backend app tsconfig The new operations.test-helpers.ts uses jest globals and broke the webpack build and tsc typecheck, which compile every non-spec file in the app. Exclude the test-helpers pattern alongside the existing spec exclusions. Co-Authored-By: Claude Fable 5 --- apps/electron-backend/tsconfig.app.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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"] }