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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 11:12:35 +02:00
1 parent 96568fc151
commit e3fcebe9fa
4 files changed
+915

No files matched your search

@@ -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],
]);
});
});
});
@@ -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();
});
});
});
@@ -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<typeof createDbServiceMock>;
playbackService: ReturnType<typeof createPlaybackServiceMock>;
pendingRestoreService: ReturnType<typeof createPendingRestoreServiceMock>;
apiService: ReturnType<typeof createApiServiceMock>;
}
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,
};
}
@@ -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);
});
});