diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts new file mode 100644 index 000000000..17ceeb626 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -0,0 +1,467 @@ +import { + DB_WORKER_OPERATIONS, + DbWorkerOperation, +} from '../workers/database-worker.types'; + +type ExposedElectronApi = Record unknown>; + +type MockIpcRenderer = { + invoke: jest.Mock; + on: jest.Mock; + off: jest.Mock; + send: jest.Mock; +}; + +type PreloadInvokeCase = { + method: string; + args: unknown[]; + channel: string; + forwardedArgs: unknown[]; +}; + +let mockExposedApi: ExposedElectronApi | null; +let mockIpcRenderer: MockIpcRenderer; +let mockGetPathForFile: jest.Mock; + +const playlistId = 'playlist-1'; +const operationId = 'operation-1'; +const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' }; +const playlists = [playlist]; +const playlistUpdates = { name: 'Updated playlist' }; +const categories = [{ category_id: '10', category_name: 'Live' }]; +const streams = [{ stream_id: 42, name: 'Channel' }]; +const favorites = [{ contentId: 1, playlistId }]; +const recentlyViewed = [{ contentId: 2, playlistId }]; +const categoryIds = [10, 11]; +const reorderUpdates = [{ content_id: 12, position: 1 }]; +const recentItemsBatch = [{ contentId: 13, playlistId }]; +const playbackData = { + contentXtreamId: 42, + contentType: 'vod', + positionSeconds: 120, +}; + +const dbPreloadCases: PreloadInvokeCase[] = [ + { + method: 'dbCreatePlaylist', + args: [playlist], + channel: 'DB_CREATE_PLAYLIST', + forwardedArgs: [playlist], + }, + { + method: 'dbGetPlaylist', + args: [playlistId], + channel: 'DB_GET_PLAYLIST', + forwardedArgs: [playlistId], + }, + { + method: 'dbUpsertAppPlaylist', + args: [playlist], + channel: 'DB_UPSERT_APP_PLAYLIST', + forwardedArgs: [playlist], + }, + { + method: 'dbUpsertAppPlaylists', + args: [playlists], + channel: 'DB_UPSERT_APP_PLAYLISTS', + forwardedArgs: [playlists], + }, + { + method: 'dbGetAppPlaylists', + args: [], + channel: 'DB_GET_APP_PLAYLISTS', + forwardedArgs: [], + }, + { + method: 'dbGetAppPlaylist', + args: [playlistId], + channel: 'DB_GET_APP_PLAYLIST', + forwardedArgs: [playlistId], + }, + { + method: 'dbUpdatePlaylist', + args: [playlistId, playlistUpdates], + channel: 'DB_UPDATE_PLAYLIST', + forwardedArgs: [playlistId, playlistUpdates], + }, + { + method: 'dbDeletePlaylist', + args: [playlistId, operationId], + channel: 'DB_DELETE_PLAYLIST', + forwardedArgs: [playlistId, operationId], + }, + { + method: 'dbDeleteXtreamContent', + args: [playlistId, operationId], + channel: 'DB_DELETE_XTREAM_CONTENT', + forwardedArgs: [playlistId, operationId], + }, + { + method: 'dbRestoreXtreamUserData', + args: [playlistId, favorites, recentlyViewed, operationId], + channel: 'DB_RESTORE_XTREAM_USER_DATA', + forwardedArgs: [playlistId, favorites, recentlyViewed, operationId], + }, + { + method: 'dbHasCategories', + args: [playlistId, 'live'], + channel: 'DB_HAS_CATEGORIES', + forwardedArgs: [playlistId, 'live'], + }, + { + method: 'dbGetCategories', + args: [playlistId, 'live'], + channel: 'DB_GET_CATEGORIES', + forwardedArgs: [playlistId, 'live'], + }, + { + method: 'dbSaveCategories', + args: [playlistId, categories, 'live', categoryIds], + channel: 'DB_SAVE_CATEGORIES', + forwardedArgs: [playlistId, categories, 'live', categoryIds], + }, + { + method: 'dbGetAllCategories', + args: [playlistId, 'live'], + channel: 'DB_GET_ALL_CATEGORIES', + forwardedArgs: [playlistId, 'live'], + }, + { + method: 'dbUpdateCategoryVisibility', + args: [categoryIds, true], + channel: 'DB_UPDATE_CATEGORY_VISIBILITY', + forwardedArgs: [categoryIds, true], + }, + { + method: 'dbHasContent', + args: [playlistId, 'movie'], + channel: 'DB_HAS_CONTENT', + forwardedArgs: [playlistId, 'movie'], + }, + { + method: 'dbGetContent', + args: [playlistId, 'movie'], + channel: 'DB_GET_CONTENT', + forwardedArgs: [playlistId, 'movie'], + }, + { + method: 'dbSaveContent', + args: [playlistId, streams, 'movie', operationId], + channel: 'DB_SAVE_CONTENT', + forwardedArgs: [playlistId, streams, 'movie', operationId], + }, + { + method: 'dbClearXtreamImportCache', + args: [playlistId, 'movie'], + channel: 'DB_CLEAR_XTREAM_IMPORT_CACHE', + forwardedArgs: [playlistId, 'movie'], + }, + { + method: 'dbSearchContent', + args: [playlistId, 'matrix', ['movie'], true], + channel: 'DB_SEARCH_CONTENT', + forwardedArgs: [playlistId, 'matrix', ['movie'], true], + }, + { + method: 'dbGlobalSearch', + args: ['matrix', ['movie'], true], + channel: 'DB_GLOBAL_SEARCH', + forwardedArgs: ['matrix', ['movie'], true], + }, + { + method: 'dbGetGlobalRecentlyAdded', + args: ['vod', 50, 'xtream'], + channel: 'DB_GET_GLOBAL_RECENTLY_ADDED', + forwardedArgs: ['vod', 50, 'xtream'], + }, + { + method: 'dbGetRecentlyViewed', + args: [], + channel: 'DB_GET_RECENTLY_VIEWED', + forwardedArgs: [], + }, + { + method: 'dbClearRecentlyViewed', + args: [], + channel: 'DB_CLEAR_RECENTLY_VIEWED', + forwardedArgs: [], + }, + { + method: 'dbAddFavorite', + args: [12, playlistId, 'https://image.example/backdrop.jpg'], + channel: 'DB_ADD_FAVORITE', + forwardedArgs: [12, playlistId, 'https://image.example/backdrop.jpg'], + }, + { + method: 'dbRemoveFavorite', + args: [12, playlistId], + channel: 'DB_REMOVE_FAVORITE', + forwardedArgs: [12, playlistId], + }, + { + method: 'dbIsFavorite', + args: [12, playlistId], + channel: 'DB_IS_FAVORITE', + forwardedArgs: [12, playlistId], + }, + { + method: 'dbGetFavorites', + args: [playlistId], + channel: 'DB_GET_FAVORITES', + forwardedArgs: [playlistId], + }, + { + method: 'dbGetGlobalFavorites', + args: [], + channel: 'DB_GET_GLOBAL_FAVORITES', + forwardedArgs: [], + }, + { + method: 'dbGetAllGlobalFavorites', + args: [], + channel: 'DB_GET_ALL_GLOBAL_FAVORITES', + forwardedArgs: [], + }, + { + method: 'dbReorderGlobalFavorites', + args: [reorderUpdates], + channel: 'DB_REORDER_GLOBAL_FAVORITES', + forwardedArgs: [reorderUpdates], + }, + { + method: 'dbGetRecentItems', + args: [playlistId], + channel: 'DB_GET_RECENT_ITEMS', + forwardedArgs: [playlistId], + }, + { + method: 'dbAddRecentItem', + args: [13, playlistId, 'https://image.example/recent.jpg'], + channel: 'DB_ADD_RECENT_ITEM', + forwardedArgs: [13, playlistId, 'https://image.example/recent.jpg'], + }, + { + method: 'dbClearPlaylistRecentItems', + args: [playlistId], + channel: 'DB_CLEAR_PLAYLIST_RECENT_ITEMS', + forwardedArgs: [playlistId], + }, + { + method: 'dbRemoveRecentItem', + args: [13, playlistId], + channel: 'DB_REMOVE_RECENT_ITEM', + forwardedArgs: [13, playlistId], + }, + { + method: 'dbRemoveRecentItemsBatch', + args: [recentItemsBatch], + channel: 'DB_REMOVE_RECENT_ITEMS_BATCH', + forwardedArgs: [recentItemsBatch], + }, + { + method: 'dbGetContentByXtreamId', + args: [42, playlistId, 'movie'], + channel: 'DB_GET_CONTENT_BY_XTREAM_ID', + forwardedArgs: [42, playlistId, 'movie'], + }, + { + method: 'dbSetContentBackdropIfMissing', + args: [12, 'https://image.example/backdrop.jpg'], + channel: 'DB_SET_CONTENT_BACKDROP_IF_MISSING', + forwardedArgs: [12, 'https://image.example/backdrop.jpg'], + }, + { + method: 'dbDeleteAllPlaylists', + args: [operationId], + channel: 'DB_DELETE_ALL_PLAYLISTS', + forwardedArgs: [operationId], + }, + { + method: 'dbCancelOperation', + args: [operationId], + channel: 'DB_CANCEL_OPERATION', + forwardedArgs: [operationId], + }, + { + method: 'dbGetAppState', + args: ['workspace:last-route'], + channel: 'DB_GET_APP_STATE', + forwardedArgs: ['workspace:last-route'], + }, + { + method: 'dbSetAppState', + args: ['workspace:last-route', '/workspace'], + channel: 'DB_SET_APP_STATE', + forwardedArgs: ['workspace:last-route', '/workspace'], + }, + { + method: 'dbSavePlaybackPosition', + args: [playlistId, playbackData], + channel: 'DB_SAVE_PLAYBACK_POSITION', + forwardedArgs: [playlistId, playbackData], + }, + { + method: 'dbGetPlaybackPosition', + args: [playlistId, 42, 'vod'], + channel: 'DB_GET_PLAYBACK_POSITION', + forwardedArgs: [playlistId, 42, 'vod'], + }, + { + method: 'dbGetSeriesPlaybackPositions', + args: [playlistId, 88], + channel: 'DB_GET_SERIES_PLAYBACK_POSITIONS', + forwardedArgs: [playlistId, 88], + }, + { + method: 'dbGetRecentPlaybackPositions', + args: [playlistId, 20], + channel: 'DB_GET_RECENT_PLAYBACK_POSITIONS', + forwardedArgs: [playlistId, 20], + }, + { + method: 'dbGetAllPlaybackPositions', + args: [playlistId], + channel: 'DB_GET_ALL_PLAYBACK_POSITIONS', + forwardedArgs: [playlistId], + }, + { + method: 'dbClearAllPlaybackPositions', + args: [playlistId], + channel: 'DB_CLEAR_ALL_PLAYBACK_POSITIONS', + forwardedArgs: [playlistId], + }, + { + method: 'dbClearPlaybackPosition', + args: [playlistId, 42, 'vod'], + channel: 'DB_CLEAR_PLAYBACK_POSITION', + forwardedArgs: [playlistId, 42, 'vod'], + }, +]; + +function getExposedApi(): ExposedElectronApi { + if (!mockExposedApi) { + throw new Error('Expected preload API to be exposed'); + } + + return mockExposedApi; +} + +describe('main preload DB IPC contract', () => { + beforeEach(async () => { + jest.resetModules(); + mockExposedApi = null; + mockGetPathForFile = jest.fn(); + mockIpcRenderer = { + invoke: jest.fn().mockResolvedValue({ success: true }), + on: jest.fn(), + off: jest.fn(), + send: jest.fn(), + }; + + jest.doMock('electron', () => ({ + contextBridge: { + exposeInMainWorld: jest.fn( + (_name: string, api: ExposedElectronApi) => { + mockExposedApi = api; + } + ), + }, + ipcRenderer: mockIpcRenderer, + webUtils: { + getPathForFile: mockGetPathForFile, + }, + })); + + await import('./main.preload'); + }); + + afterEach(() => { + jest.dontMock('electron'); + }); + + it('covers every worker-backed DB operation exposed by the preload bridge', () => { + const workerChannels = dbPreloadCases + .map((contractCase) => contractCase.channel) + .filter( + (channel): channel is DbWorkerOperation => + channel !== 'DB_CANCEL_OPERATION' && + DB_WORKER_OPERATIONS.includes(channel as DbWorkerOperation) + ); + + expect(new Set(workerChannels)).toEqual(new Set(DB_WORKER_OPERATIONS)); + }); + + it.each(dbPreloadCases)( + '$method invokes $channel with the expected arguments', + async ({ method, args, channel, forwardedArgs }) => { + const api = getExposedApi(); + + await api[method](...args); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + channel, + ...forwardedArgs + ); + } + ); + + it('forwards request-scoped DB operation events and unregisters the listener', () => { + const api = getExposedApi(); + const callback = jest.fn(); + const event = { + operationId, + operation: 'save-content', + status: 'progress', + current: 5, + total: 10, + }; + + const unsubscribe = api.onDbOperationEvent(callback) as () => void; + const handler = mockIpcRenderer.on.mock.calls[0][1]; + + handler({}, event); + + expect(mockIpcRenderer.on).toHaveBeenCalledWith( + 'DB_OPERATION_EVENT', + handler + ); + expect(callback).toHaveBeenCalledWith(event); + + unsubscribe(); + + expect(mockIpcRenderer.off).toHaveBeenCalledWith( + 'DB_OPERATION_EVENT', + handler + ); + }); + + it('keeps the legacy save-content progress bridge scoped to progress events', () => { + const api = getExposedApi(); + const callback = jest.fn(); + + api.onDbSaveContentProgress(callback); + const handler = mockIpcRenderer.on.mock.calls[0][1]; + + handler({}, { operation: 'delete-playlist', status: 'progress' }); + handler({}, { operation: 'save-content', status: 'started' }); + handler( + {}, + { + operation: 'save-content', + status: 'progress', + current: 25, + increment: 5, + } + ); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(5); + + api.removeDbSaveContentProgress(); + + expect(mockIpcRenderer.off).toHaveBeenCalledWith( + 'DB_OPERATION_EVENT', + handler + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts new file mode 100644 index 000000000..aac035ac4 --- /dev/null +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts @@ -0,0 +1,462 @@ +import { + DB_WORKER_OPERATIONS, + DbOperationEvent, + DbWorkerOperation, +} from '../../workers/database-worker.types'; + +type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise; + +type MockIpcEvent = { + sender: { + isDestroyed: jest.Mock; + send: jest.Mock; + }; +}; + +type WorkerIpcContractCase = { + operation: DbWorkerOperation; + args: unknown[]; + payload: unknown; + forwardsEvents?: boolean; +}; + +const mockRegisteredHandlers = new Map(); +const mockWorkerRequest = jest.fn(); +const mockWorkerCancel = jest.fn(); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, +})); + +jest.mock('../../services/database-worker-client', () => ({ + databaseWorkerClient: { + request: (...args: unknown[]) => mockWorkerRequest(...args), + cancel: (...args: unknown[]) => mockWorkerCancel(...args), + }, +})); + +const playlistId = 'playlist-1'; +const operationId = 'operation-1'; +const playlist = { id: playlistId, name: 'Playlist', type: 'xtream' }; +const playlists = [playlist]; +const playlistUpdates = { name: 'Updated playlist' }; +const categories = [{ category_id: '10', category_name: 'Live' }]; +const streams = [{ stream_id: 42, name: 'Channel' }]; +const favorites = [{ contentId: 1, playlistId }]; +const recentlyViewed = [{ contentId: 2, playlistId }]; +const categoryIds = [10, 11]; +const reorderUpdates = [{ content_id: 12, position: 1 }]; +const recentItemsBatch = [{ contentId: 13, playlistId }]; +const playbackData = { + contentXtreamId: 42, + contentType: 'vod', + positionSeconds: 120, +}; + +const workerIpcContractCases: WorkerIpcContractCase[] = [ + { + operation: 'DB_CREATE_PLAYLIST', + args: [playlist], + payload: playlist, + }, + { + operation: 'DB_UPSERT_APP_PLAYLIST', + args: [playlist], + payload: playlist, + }, + { + operation: 'DB_UPSERT_APP_PLAYLISTS', + args: [playlists], + payload: playlists, + }, + { + operation: 'DB_GET_APP_PLAYLISTS', + args: [], + payload: {}, + }, + { + operation: 'DB_GET_APP_PLAYLIST', + args: [playlistId], + payload: { playlistId }, + }, + { + operation: 'DB_GET_PLAYLIST', + args: [playlistId], + payload: { playlistId }, + }, + { + operation: 'DB_UPDATE_PLAYLIST', + args: [playlistId, playlistUpdates], + payload: { playlistId, updates: playlistUpdates }, + }, + { + operation: 'DB_GET_APP_STATE', + args: ['workspace:last-route'], + payload: { key: 'workspace:last-route' }, + }, + { + operation: 'DB_SET_APP_STATE', + args: ['workspace:last-route', '/workspace'], + payload: { key: 'workspace:last-route', value: '/workspace' }, + }, + { + operation: 'DB_HAS_CATEGORIES', + args: [playlistId, 'live'], + payload: { playlistId, type: 'live' }, + }, + { + operation: 'DB_GET_CATEGORIES', + args: [playlistId, 'live'], + payload: { playlistId, type: 'live' }, + }, + { + operation: 'DB_SAVE_CATEGORIES', + args: [playlistId, categories, 'live', categoryIds], + payload: { + playlistId, + categories, + type: 'live', + hiddenCategoryXtreamIds: categoryIds, + }, + }, + { + operation: 'DB_GET_ALL_CATEGORIES', + args: [playlistId, 'live'], + payload: { playlistId, type: 'live' }, + }, + { + operation: 'DB_UPDATE_CATEGORY_VISIBILITY', + args: [categoryIds, true], + payload: { categoryIds, hidden: true }, + }, + { + operation: 'DB_HAS_CONTENT', + args: [playlistId, 'movie'], + payload: { playlistId, type: 'movie' }, + }, + { + operation: 'DB_GET_CONTENT', + args: [playlistId, 'movie'], + payload: { playlistId, type: 'movie' }, + }, + { + operation: 'DB_GET_GLOBAL_RECENTLY_ADDED', + args: ['vod', 50, 'xtream'], + payload: { kind: 'vod', limit: 50, playlistType: 'xtream' }, + }, + { + operation: 'DB_SAVE_CONTENT', + args: [playlistId, streams, 'movie', operationId], + payload: { playlistId, streams, type: 'movie', operationId }, + forwardsEvents: true, + }, + { + operation: 'DB_CLEAR_XTREAM_IMPORT_CACHE', + args: [playlistId, 'movie'], + payload: { playlistId, type: 'movie' }, + }, + { + operation: 'DB_GET_CONTENT_BY_XTREAM_ID', + args: [42, playlistId, 'movie'], + payload: { xtreamId: 42, playlistId, contentType: 'movie' }, + }, + { + operation: 'DB_SET_CONTENT_BACKDROP_IF_MISSING', + args: [12, 'https://image.example/backdrop.jpg'], + payload: { + contentId: 12, + backdropUrl: 'https://image.example/backdrop.jpg', + }, + }, + { + operation: 'DB_SEARCH_CONTENT', + args: [playlistId, 'matrix', ['movie'], true], + payload: { + playlistId, + searchTerm: 'matrix', + types: ['movie'], + excludeHidden: true, + }, + }, + { + operation: 'DB_GLOBAL_SEARCH', + args: ['matrix', ['movie'], true], + payload: { + searchTerm: 'matrix', + types: ['movie'], + excludeHidden: true, + }, + }, + { + operation: 'DB_DELETE_PLAYLIST', + args: [playlistId, operationId], + payload: { playlistId, operationId }, + forwardsEvents: true, + }, + { + operation: 'DB_DELETE_ALL_PLAYLISTS', + args: [operationId], + payload: { operationId }, + forwardsEvents: true, + }, + { + operation: 'DB_DELETE_XTREAM_CONTENT', + args: [playlistId, operationId], + payload: { playlistId, operationId }, + forwardsEvents: true, + }, + { + operation: 'DB_RESTORE_XTREAM_USER_DATA', + args: [playlistId, favorites, recentlyViewed, operationId], + payload: { playlistId, favorites, recentlyViewed, operationId }, + forwardsEvents: true, + }, + { + operation: 'DB_ADD_FAVORITE', + args: [12, playlistId, 'https://image.example/backdrop.jpg'], + payload: { + contentId: 12, + playlistId, + backdropUrl: 'https://image.example/backdrop.jpg', + }, + }, + { + operation: 'DB_REMOVE_FAVORITE', + args: [12, playlistId], + payload: { contentId: 12, playlistId }, + }, + { + operation: 'DB_IS_FAVORITE', + args: [12, playlistId], + payload: { contentId: 12, playlistId }, + }, + { + operation: 'DB_GET_FAVORITES', + args: [playlistId], + payload: { playlistId }, + }, + { + operation: 'DB_GET_GLOBAL_FAVORITES', + args: [], + payload: {}, + }, + { + operation: 'DB_GET_ALL_GLOBAL_FAVORITES', + args: [], + payload: {}, + }, + { + operation: 'DB_REORDER_GLOBAL_FAVORITES', + args: [reorderUpdates], + payload: { updates: reorderUpdates }, + }, + { + operation: 'DB_GET_RECENTLY_VIEWED', + args: [], + payload: {}, + }, + { + operation: 'DB_CLEAR_RECENTLY_VIEWED', + args: [], + payload: {}, + }, + { + operation: 'DB_GET_RECENT_ITEMS', + args: [playlistId], + payload: { playlistId }, + }, + { + operation: 'DB_ADD_RECENT_ITEM', + args: [13, playlistId, 'https://image.example/recent.jpg'], + payload: { + contentId: 13, + playlistId, + backdropUrl: 'https://image.example/recent.jpg', + }, + }, + { + operation: 'DB_CLEAR_PLAYLIST_RECENT_ITEMS', + args: [playlistId], + payload: { playlistId }, + }, + { + operation: 'DB_REMOVE_RECENT_ITEM', + args: [13, playlistId], + payload: { contentId: 13, playlistId }, + }, + { + operation: 'DB_REMOVE_RECENT_ITEMS_BATCH', + args: [recentItemsBatch], + payload: { items: recentItemsBatch }, + }, + { + operation: 'DB_SAVE_PLAYBACK_POSITION', + args: [playlistId, playbackData], + payload: { playlistId, data: playbackData }, + }, + { + operation: 'DB_GET_PLAYBACK_POSITION', + args: [playlistId, 42, 'vod'], + payload: { playlistId, contentXtreamId: 42, contentType: 'vod' }, + }, + { + operation: 'DB_GET_SERIES_PLAYBACK_POSITIONS', + args: [playlistId, 88], + payload: { playlistId, seriesXtreamId: 88 }, + }, + { + operation: 'DB_GET_RECENT_PLAYBACK_POSITIONS', + args: [playlistId, 20], + payload: { playlistId, limit: 20 }, + }, + { + operation: 'DB_GET_ALL_PLAYBACK_POSITIONS', + args: [playlistId], + payload: { playlistId }, + }, + { + operation: 'DB_CLEAR_ALL_PLAYBACK_POSITIONS', + args: [playlistId], + payload: { playlistId }, + }, + { + operation: 'DB_CLEAR_PLAYBACK_POSITION', + args: [playlistId, 42, 'vod'], + payload: { playlistId, contentXtreamId: 42, contentType: 'vod' }, + }, +]; + +async function importDatabaseEventModules(): Promise { + await import('./category.events'); + await import('./content.events'); + await import('./favorites.events'); + await import('./playback-position.events'); + await import('./playlist.events'); + await import('./recently-viewed.events'); + await import('./xtream.events'); +} + +function createIpcEvent(): MockIpcEvent { + return { + sender: { + isDestroyed: jest.fn(() => false), + send: jest.fn(), + }, + }; +} + +function getHandler(channel: string): IpcHandler { + const handler = mockRegisteredHandlers.get(channel); + + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + + return handler; +} + +function getLastWorkerRequestCall(): unknown[] { + return mockWorkerRequest.mock.calls[ + mockWorkerRequest.mock.calls.length - 1 + ]; +} + +describe('database worker IPC contract', () => { + beforeEach(async () => { + jest.resetModules(); + mockRegisteredHandlers.clear(); + mockWorkerRequest.mockReset().mockResolvedValue({ success: true }); + mockWorkerCancel.mockReset().mockResolvedValue({ success: true }); + + await importDatabaseEventModules(); + }); + + it('registers an IPC handler for every worker operation', () => { + const registeredDbChannels = [...mockRegisteredHandlers.keys()] + .filter((channel) => channel !== 'DB_CANCEL_OPERATION') + .sort(); + + expect(registeredDbChannels).toEqual([...DB_WORKER_OPERATIONS].sort()); + expect(mockRegisteredHandlers.has('DB_CANCEL_OPERATION')).toBe(true); + }); + + it.each(workerIpcContractCases)( + '$operation builds the expected worker request payload', + async ({ operation, args, payload, forwardsEvents }) => { + const ipcEvent = createIpcEvent(); + const handler = getHandler(operation); + + await handler(ipcEvent, ...args); + + if (forwardsEvents) { + expect(mockWorkerRequest).toHaveBeenLastCalledWith( + operation, + payload, + { onEvent: expect.any(Function) } + ); + } else { + expect(mockWorkerRequest).toHaveBeenLastCalledWith( + operation, + payload + ); + } + } + ); + + it('covers every worker operation in the payload contract cases', () => { + expect( + new Set(workerIpcContractCases.map(({ operation }) => operation)) + ).toEqual(new Set(DB_WORKER_OPERATIONS)); + }); + + it('forwards request-scoped worker events only while the renderer exists', async () => { + const ipcEvent = createIpcEvent(); + const workerEvent: DbOperationEvent = { + operationId, + operation: 'save-content', + status: 'progress', + current: 5, + total: 10, + }; + + await getHandler('DB_SAVE_CONTENT')( + ipcEvent, + playlistId, + streams, + 'movie', + operationId + ); + + const options = getLastWorkerRequestCall()[2] as { + onEvent: (event: DbOperationEvent) => void; + }; + + options.onEvent(workerEvent); + + expect(ipcEvent.sender.send).toHaveBeenCalledWith( + 'DB_OPERATION_EVENT', + workerEvent + ); + + ipcEvent.sender.send.mockClear(); + ipcEvent.sender.isDestroyed.mockReturnValue(true); + + options.onEvent(workerEvent); + + expect(ipcEvent.sender.send).not.toHaveBeenCalled(); + }); + + it('routes DB cancellation through the worker client cancel channel', async () => { + const result = await getHandler('DB_CANCEL_OPERATION')( + createIpcEvent(), + operationId + ); + + expect(mockWorkerCancel).toHaveBeenCalledWith(operationId); + expect(result).toEqual({ success: true }); + }); +});