Files
iptvnator/apps/electron-backend/src/app/api/main.preload.spec.ts
T

276 lines
8.2 KiB
TypeScript

import {
DB_WORKER_OPERATIONS,
DbWorkerOperation,
} from '../workers/database-worker.types';
import {
APP_UPDATE_CHECK,
APP_UPDATE_DOWNLOAD,
APP_UPDATE_GET_RELEASE_NOTES,
APP_UPDATE_GET_STATUS,
APP_UPDATE_INSTALL,
APP_UPDATE_STATUS_CHANGED,
} from '@iptvnator/shared/interfaces';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import {
dbPreloadCases,
epgPreloadCases,
operationId,
} from './main.preload.spec-data';
type ExposedElectronApi = ElectronBridgeApi &
Record<string, (...args: unknown[]) => unknown>;
type MockIpcRenderer = {
invoke: jest.Mock;
on: jest.Mock;
off: jest.Mock;
send: jest.Mock;
};
let mockExposedApi: ExposedElectronApi | null;
let mockIpcRenderer: MockIpcRenderer;
let mockGetPathForFile: jest.Mock;
function getExposedApi(): ExposedElectronApi {
if (!mockExposedApi) {
throw new Error('Expected preload API to be exposed');
}
return mockExposedApi;
}
function callExposedApiMethod(
api: ExposedElectronApi,
method: string,
args: unknown[]
): unknown {
const callableApi = api as Record<string, (...args: unknown[]) => unknown>;
return callableApi[method](...args);
}
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 callExposedApiMethod(api, method, args);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
channel,
...forwardedArgs
);
}
);
it.each(epgPreloadCases)(
'$method invokes $channel with the expected arguments',
async ({ method, args, channel, forwardedArgs }) => {
const api = getExposedApi();
await callExposedApiMethod(api, method, args);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
channel,
...forwardedArgs
);
}
);
it('forwards scoped user-agent override arguments', async () => {
const api = getExposedApi();
await api.setUserAgent(
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
'set-user-agent',
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
);
});
it('exposes app update commands and status events through the typed bridge', async () => {
const api = getExposedApi();
const callback = jest.fn();
const status = {
currentVersion: '0.22.0',
latestVersion: '0.23.0',
manualDownloadUrl:
'https://github.com/4gray/iptvnator/releases/latest',
status: 'available',
supportedSelfUpdate: true,
};
await api.getAppUpdateStatus();
await api.checkForAppUpdate();
await api.downloadAppUpdate();
await api.installAppUpdate();
await api.getAppUpdateReleaseNotes({
direction: 'previous',
version: 'v0.23.0',
});
const unsubscribe = api.onAppUpdateStatusChange(callback);
const handler = mockIpcRenderer.on.mock.calls.at(-1)?.[1];
handler({}, status);
unsubscribe();
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
APP_UPDATE_GET_STATUS
);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(APP_UPDATE_CHECK);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
APP_UPDATE_DOWNLOAD
);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(APP_UPDATE_INSTALL);
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
APP_UPDATE_GET_RELEASE_NOTES,
{
direction: 'previous',
version: 'v0.23.0',
}
);
expect(mockIpcRenderer.on).toHaveBeenCalledWith(
APP_UPDATE_STATUS_CHANGED,
expect.any(Function)
);
expect(callback).toHaveBeenCalledWith(status);
expect(mockIpcRenderer.off).toHaveBeenCalledWith(
APP_UPDATE_STATUS_CHANGED,
handler
);
});
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',
expect.any(Function)
);
expect(callback).toHaveBeenCalledWith(event);
unsubscribe();
expect(mockIpcRenderer.off).toHaveBeenCalledWith(
'DB_OPERATION_EVENT',
handler
);
});
it('preserves a structured playlist cancellation result across the context bridge', async () => {
const api = getExposedApi();
const payload = {
operationId: 'playlist-refresh-cancelled',
playlistId: 'playlist-1',
title: 'Large playlist',
url: 'http://127.0.0.1/large.m3u',
};
const cancelledResult = {
operationId: payload.operationId,
type: 'playlist-refresh-cancelled',
} as const;
mockIpcRenderer.invoke.mockResolvedValueOnce(cancelledResult);
await expect(api.refreshPlaylist(payload)).resolves.toEqual(
cancelledResult
);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
'PLAYLIST:REFRESH',
payload
);
});
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
);
});
});