Files
iptvnator/libs/services/src/lib/runtime-capabilities.service.spec.ts
T
genrichh93-ui 188f5c4b56 feat(downloads): pause and resume support for the download manager (#1147)
Adds a paused state to the Electron download manager with a full partial-file lifecycle:

- Pause keeps the .part and byte progress; cancel discards them; every lifecycle stage (queued, active, mid-transfer) is pausable.
- Resume continues via HTTP Range with If-Range entity validation (strong ETag / Last-Modified persisted in the new resume_validator column, idempotent migration incl. legacy-table rebuild). Non-206 answers restart from zero over the same .part; the 206 Content-Range offset is verified; responses that end before the advertised size are retained for a Range retry instead of being committed as completed.
- Crash recovery converts interrupted transfers to paused, keeps queued-with-partial rows resumable, and commits finalizations that crashed before the DB update.
- Destination collisions are non-destructive (retained partials finalize to the next numbered name); locked .part files never lose their DB owner across cancel/remove/restart; resume claims rows atomically and the queue dedupes ids.
- Stored request headers are re-filtered through the User-Agent/Origin/Referer allowlist on read, URL-derived extensions are sanitized, resume appends never follow symlinks, and transfer errors are logged by message only.
- UI: pause/resume/cancel/retry/remove surface failures in a snackbar; paused items show an active Resume button in VOD/episode detail views; translations for all 18 locales.
- Runtime split into download-runtime/transfer/finalize/broadcast modules; +30 unit tests and an Electron E2E covering pause -> retained .part -> Range/If-Range resume -> byte-exact assembly.

Co-authored-by: genrichh93-ui <genrichh93@users.noreply.github.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-24 18:40:30 +02:00

549 lines
21 KiB
TypeScript

import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
describe('RuntimeCapabilitiesService', () => {
const testWindow = window as unknown as {
electron?: Record<string, unknown>;
};
const originalElectron = testWindow.electron;
afterEach(() => {
testWindow.electron = originalElectron;
});
it('reports browser PWA capabilities when the Electron bridge is absent', () => {
testWindow.electron = undefined;
const service = new RuntimeCapabilitiesService();
expect(service.environment).toBe('pwa');
expect(service.isPwa).toBe(true);
expect(service.isElectron).toBe(false);
expect(service.platform).toBeUndefined();
expect(service.isMacOS).toBe(false);
expect(service.supportsEpg).toBe(false);
expect(service.supportsSqlite).toBe(false);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsPlaybackPositionStorage).toBe(false);
expect(service.supportsPlaybackPositionUpdates).toBe(false);
expect(service.supportsAppStateStorage).toBe(false);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgProgress).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgCurrentProgramBatch).toBe(false);
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
it('reports Electron capabilities from the available preload bridge methods', () => {
testWindow.electron = {
platform: 'darwin',
dbDeleteAllPlaylists: jest.fn(),
dbDeletePlaylist: jest.fn(),
dbGetAppPlaylist: jest.fn(),
dbGetAppPlaylists: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbAddFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetPlaylist: jest.fn(),
dbCreatePlaylist: jest.fn(),
dbUpdatePlaylist: jest.fn(),
dbHasCategories: jest.fn(),
dbGetCategories: jest.fn(),
dbSaveCategories: jest.fn(),
dbGetAllCategories: jest.fn(),
dbUpdateCategoryVisibility: jest.fn(),
dbHasContent: jest.fn(),
dbGetContent: jest.fn(),
dbSaveContent: jest.fn(),
dbSearchContent: jest.fn(),
dbIsFavorite: jest.fn(),
dbSavePlaybackPosition: jest.fn(),
dbGetPlaybackPosition: jest.fn(),
dbGetSeriesPlaybackPositions: jest.fn(),
dbGetRecentPlaybackPositions: jest.fn(),
dbGetAllPlaybackPositions: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
dbClearPlaybackPosition: jest.fn(),
onPlaybackPositionUpdate: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
downloadsPause: jest.fn(),
downloadsResume: jest.fn(),
downloadsRetry: jest.fn(),
downloadsRemove: jest.fn(),
downloadsGetList: jest.fn(),
downloadsGet: jest.fn(),
downloadsGetDefaultFolder: jest.fn(),
downloadsSelectFolder: jest.fn(),
downloadsRevealFile: jest.fn(),
downloadsPlayFile: jest.fn(),
downloadsClearCompleted: jest.fn(),
onDownloadsUpdate: jest.fn(),
refreshPlaylist: jest.fn(),
cancelPlaylistRefresh: jest.fn(),
onPlaylistRefreshEvent: jest.fn(),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
setMpvPlayerPath: jest.fn(),
setVlcPlayerPath: jest.fn(),
prepareEmbeddedMpv: jest.fn(),
saveFileDialog: jest.fn(),
writeFile: jest.fn(),
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(),
onRemoteControlCommand: jest.fn(),
xtreamRequest: jest.fn(),
fetchEpg: jest.fn(),
getChannelPrograms: jest.fn(),
getCurrentProgramsBatch: jest.fn(),
getEpgChannelMetadata: jest.fn(),
checkEpgFreshness: jest.fn(),
onEpgProgress: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.environment).toBe('electron');
expect(service.isElectron).toBe(true);
expect(service.isPwa).toBe(false);
expect(service.platform).toBe('darwin');
expect(service.isMacOS).toBe(true);
expect(service.supportsEpg).toBe(true);
expect(service.supportsSqlite).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(true);
expect(service.supportsDownloads).toBe(true);
expect(service.supportsPortalActivityStorage).toBe(true);
expect(service.supportsPlaybackPositionStorage).toBe(true);
expect(service.supportsPlaybackPositionUpdates).toBe(true);
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
expect(service.supportsPlaylistRefresh).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(true);
expect(service.supportsEmbeddedMpv).toBe(true);
expect(service.supportsDesktopFileSave).toBe(true);
expect(service.supportsRemoteControl).toBe(true);
expect(service.supportsXtreamSectionNavigation).toBe(true);
expect(service.supportsEpgImport).toBe(true);
expect(service.supportsEpgProgress).toBe(true);
expect(service.supportsEpgProgramLookup).toBe(true);
expect(service.supportsEpgCurrentProgramBatch).toBe(true);
expect(service.supportsEpgChannelMetadata).toBe(true);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgChannelBrowser).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
});
it('keeps feature-specific capabilities false when an Electron bridge is partial', () => {
testWindow.electron = {
updateRemoteControlStatus: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.environment).toBe('electron');
expect(service.platform).toBeUndefined();
expect(service.isMacOS).toBe(false);
expect(service.supportsEpg).toBe(false);
expect(service.supportsSqlite).toBe(false);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsDownloads).toBe(false);
expect(service.supportsPortalActivityStorage).toBe(false);
expect(service.supportsPlaybackPositionStorage).toBe(false);
expect(service.supportsPlaybackPositionUpdates).toBe(false);
expect(service.supportsAppStateStorage).toBe(false);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
expect(service.supportsPlaylistRefresh).toBe(false);
expect(service.supportsManagedExternalPlayers).toBe(false);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
expect(service.supportsEmbeddedMpv).toBe(false);
expect(service.supportsDesktopFileSave).toBe(false);
expect(service.supportsRemoteControl).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(false);
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgProgress).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgCurrentProgramBatch).toBe(false);
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
it('reads the bridge dynamically so tests and late preload setup stay accurate', () => {
testWindow.electron = undefined;
const service = new RuntimeCapabilitiesService();
expect(service.isPwa).toBe(true);
testWindow.electron = {};
expect(service.isElectron).toBe(true);
expect(service.environment).toBe('electron');
});
it('keeps the Xtream SQLite data source disabled when only generic SQLite methods exist', () => {
testWindow.electron = createPlaylistStorageBridge();
const service = new RuntimeCapabilitiesService();
expect(service.supportsSqlite).toBe(true);
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('checks app state storage without requiring full playlist SQLite support', () => {
testWindow.electron = {
dbGetAppState: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsAppStateStorage).toBe(false);
testWindow.electron = {
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
};
expect(service.supportsAppStateStorage).toBe(true);
expect(service.supportsSqlite).toBe(false);
});
it('checks Stalker playlist SQLite sync without requiring the full Xtream data source', () => {
testWindow.electron = {
dbGetPlaylist: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsStalkerPlaylistSqliteSync).toBe(false);
testWindow.electron = {
dbGetPlaylist: jest.fn(),
dbCreatePlaylist: jest.fn(),
};
expect(service.supportsStalkerPlaylistSqliteSync).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('decouples external player launch support from path-setting support', () => {
testWindow.electron = {
openInMpv: jest.fn(),
openInVlc: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(false);
testWindow.electron = {
openInMpv: jest.fn(),
openInVlc: jest.fn(),
setMpvPlayerPath: jest.fn(),
setVlcPlayerPath: jest.fn(),
};
expect(service.supportsManagedExternalPlayers).toBe(true);
expect(service.supportsExternalPlayerPathSettings).toBe(true);
});
it('requires the complete EPG preload surface', () => {
testWindow.electron = {
fetchEpg: jest.fn(),
getChannelPrograms: jest.fn(),
checkEpgFreshness: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsEpg).toBe(false);
testWindow.electron = {
fetchEpg: jest.fn(),
getChannelPrograms: jest.fn(),
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
expect(service.supportsEpg).toBe(true);
});
it('exposes EPG capabilities by the specific preload surface they need', () => {
testWindow.electron = {
fetchEpg: jest.fn(),
onEpgProgress: jest.fn(),
getChannelPrograms: jest.fn(),
checkEpgFreshness: jest.fn(),
forceFetchEpg: jest.fn(),
clearEpgData: jest.fn(),
clearEpgDataForSource: jest.fn(),
getEpgChannelsByRange: jest.fn(),
searchEpgPrograms: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.supportsEpg).toBe(true);
expect(service.supportsEpgImport).toBe(true);
expect(service.supportsEpgProgress).toBe(true);
expect(service.supportsEpgProgramLookup).toBe(true);
expect(service.supportsEpgCurrentProgramBatch).toBe(false);
expect(service.supportsEpgChannelMetadata).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgChannelBrowser).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
testWindow.electron = {
checkEpgFreshness: jest.fn(),
};
expect(service.supportsEpg).toBe(false);
expect(service.supportsEpgSourceFreshness).toBe(true);
expect(service.supportsEpgImport).toBe(false);
expect(service.supportsEpgProgramLookup).toBe(false);
expect(service.supportsEpgDataManagement).toBe(false);
expect(service.supportsEpgChannelBrowser).toBe(false);
expect(service.supportsEpgProgramSearch).toBe(false);
});
it('requires the complete downloads preload surface', () => {
testWindow.electron = {
downloadsGetList: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsDownloads).toBe(false);
testWindow.electron = {
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
downloadsPause: jest.fn(),
downloadsResume: jest.fn(),
downloadsRetry: jest.fn(),
downloadsRemove: jest.fn(),
downloadsGetList: jest.fn(),
downloadsGet: jest.fn(),
downloadsGetDefaultFolder: jest.fn(),
downloadsSelectFolder: jest.fn(),
downloadsRevealFile: jest.fn(),
downloadsPlayFile: jest.fn(),
downloadsClearCompleted: jest.fn(),
onDownloadsUpdate: jest.fn(),
};
expect(service.supportsDownloads).toBe(true);
});
it('requires both desktop file-save preload methods', () => {
testWindow.electron = {
saveFileDialog: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsDesktopFileSave).toBe(false);
testWindow.electron = {
saveFileDialog: jest.fn(),
writeFile: jest.fn(),
};
expect(service.supportsDesktopFileSave).toBe(true);
});
it('requires the complete playlist refresh preload surface', () => {
testWindow.electron = {
refreshPlaylist: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsPlaylistRefresh).toBe(false);
testWindow.electron = {
refreshPlaylist: jest.fn(),
cancelPlaylistRefresh: jest.fn(),
onPlaylistRefreshEvent: jest.fn(),
};
expect(service.supportsPlaylistRefresh).toBe(true);
});
it('requires the complete playlist storage SQLite preload surface', () => {
testWindow.electron = {
dbGetAppPlaylists: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsSqlite).toBe(false);
testWindow.electron = createPlaylistStorageBridge();
expect(service.supportsSqlite).toBe(true);
});
it('checks playback-position storage and update bridge capabilities independently', () => {
testWindow.electron = {
dbSavePlaybackPosition: jest.fn(),
dbGetPlaybackPosition: jest.fn(),
dbGetSeriesPlaybackPositions: jest.fn(),
dbGetRecentPlaybackPositions: jest.fn(),
dbGetAllPlaybackPositions: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.supportsPlaybackPositionStorage).toBe(false);
expect(service.supportsPlaybackPositionUpdates).toBe(false);
testWindow.electron = {
...testWindow.electron,
dbClearPlaybackPosition: jest.fn(),
};
expect(service.supportsPlaybackPositionStorage).toBe(true);
expect(service.supportsPlaybackPositionUpdates).toBe(false);
testWindow.electron = {
...testWindow.electron,
onPlaybackPositionUpdate: jest.fn(),
};
expect(service.supportsPlaybackPositionStorage).toBe(true);
expect(service.supportsPlaybackPositionUpdates).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('supports Xtream section navigation in Electron when Xtream API transport is available', () => {
testWindow.electron = {
xtreamRequest: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
it('supports Xtream section navigation in Electron when only the SQLite data source is available', () => {
testWindow.electron = createXtreamSqliteBridge();
const service = new RuntimeCapabilitiesService();
expect(service.isElectron).toBe(true);
expect(service.supportsXtreamSqliteDataSource).toBe(true);
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
});
function createPlaylistStorageBridge(): Record<string, jest.Mock> {
return {
dbDeleteAllPlaylists: jest.fn(),
dbDeletePlaylist: jest.fn(),
dbGetAppPlaylist: jest.fn(),
dbGetAppPlaylists: jest.fn(),
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(),
};
}
function createXtreamSqliteBridge(): Record<string, jest.Mock> {
return {
dbAddFavorite: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearAllPlaybackPositions: jest.fn(),
dbClearPlaybackPosition: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbCreatePlaylist: jest.fn(),
dbDeletePlaylist: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbGetAllCategories: jest.fn(),
dbGetAllPlaybackPositions: jest.fn(),
dbGetAppState: jest.fn(),
dbGetCategories: jest.fn(),
dbGetContent: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetFavorites: jest.fn(),
dbGetPlaylist: jest.fn(),
dbGetPlaybackPosition: jest.fn(),
dbGetRecentItems: jest.fn(),
dbGetRecentPlaybackPositions: jest.fn(),
dbGetSeriesPlaybackPositions: jest.fn(),
dbHasCategories: jest.fn(),
dbHasContent: jest.fn(),
dbIsFavorite: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbSaveCategories: jest.fn(),
dbSaveContent: jest.fn(),
dbSavePlaybackPosition: jest.fn(),
dbSearchContent: jest.fn(),
dbSetAppState: jest.fn(),
dbUpdateCategoryVisibility: jest.fn(),
dbUpdatePlaylist: jest.fn(),
};
}