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); + }); });