mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
96568fc151
commit
e3fcebe9fa
4 files changed
+915
No files matched your search
+391
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+350
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+118
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user