From 26331676f945379667aebaebe1b4f75610f10ad8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:43:52 +0200 Subject: [PATCH] fix(epg): await mapping queries so lookups fail soft (#1279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four EPG mapping handlers wrap their DB call in try/catch but return the promise instead of awaiting it. An async function *adopts* a returned promise rather than awaiting it, so the catch block only ever fired when getDatabase() itself threw — a rejection from the underlying query escaped to the IPC caller instead of returning the intended null / {success:false} / [] fallback. Add the missing await to handleGetEpgMapping, handleSetEpgMapping, handleDeleteEpgMapping and handleSearchEpgChannels, matching handleGetEpgMappingsBatch and resolveChannelIds in the same module, which already awaited and so already failed soft for both cases. This restores the contract stated in the module's own doc comment: a mapping lookup must never take down an EPG request. The behavior predates the max-lines split in #1278, which carried it over verbatim from epg.events.ts. The new spec drives the real IPC handlers captured from ipcMain.handle, so it asserts the contract that matters: the caller gets a fallback, not a rejected promise. Reverting the four awaits fails exactly those four handlers and leaves the already-correct batch handler green. Co-authored-by: Claude Opus 5 --- .changes/epg-mapping-lookup-fail-soft.md | 9 + .../src/app/events/epg-mapping.events.spec.ts | 174 ++++++++++++++++++ .../src/app/events/epg-mapping.service.ts | 8 +- 3 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 .changes/epg-mapping-lookup-fail-soft.md create mode 100644 apps/electron-backend/src/app/events/epg-mapping.events.spec.ts diff --git a/.changes/epg-mapping-lookup-fail-soft.md b/.changes/epg-mapping-lookup-fail-soft.md new file mode 100644 index 000000000..dc35aebbe --- /dev/null +++ b/.changes/epg-mapping-lookup-fail-soft.md @@ -0,0 +1,9 @@ +--- +type: fix +area: epg +--- + +A database error while reading or saving a manual EPG channel mapping no longer +surfaces as a failed request. Looking up, saving, deleting, and searching +mappings now fall back quietly, so a transient storage hiccup can no longer take +down the EPG panel or the "Map EPG channel" dialog. diff --git a/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts new file mode 100644 index 000000000..0af8125fe --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts @@ -0,0 +1,174 @@ +import type EpgEventsType from './epg.events'; + +const getDatabase = jest.fn(); +const getEpgMapping = jest.fn(); +const getEpgMappingsBatch = jest.fn(); +const setEpgMapping = jest.fn(); +const deleteEpgMapping = jest.fn(); +const searchEpgChannels = jest.fn(); +const ipcHandlers = new Map< + string, + (event: unknown, args: unknown) => Promise +>(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => [], + }, + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../database/operations/epg-mapping.operations', () => ({ + getEpgMapping: (...args: unknown[]) => getEpgMapping(...args), + getEpgMappingsBatch: (...args: unknown[]) => getEpgMappingsBatch(...args), + setEpgMapping: (...args: unknown[]) => setEpgMapping(...args), + deleteEpgMapping: (...args: unknown[]) => deleteEpgMapping(...args), + searchEpgChannels: (...args: unknown[]) => searchEpgChannels(...args), +})); + +/** + * The EPG mapping IPC handlers must fail soft: a mapping lookup is a side + * concern of an EPG request and must never take that request down. The + * handlers wrap the DB call in try/catch, but a bare `return promise` inside + * an async function is *adopted*, not awaited — so the catch block only ever + * saw a synchronous `getDatabase()` failure while a rejection from the query + * itself escaped to the IPC caller. These tests pin both failure modes. + */ +describe('EPG mapping IPC handlers', () => { + let EpgEvents: typeof EpgEventsType; + + beforeEach(async () => { + jest.resetModules(); + ipcHandlers.clear(); + [ + getDatabase, + getEpgMapping, + getEpgMappingsBatch, + setEpgMapping, + deleteEpgMapping, + searchEpgChannels, + ].forEach((mock) => mock.mockReset()); + + getDatabase.mockResolvedValue({}); + + ({ default: EpgEvents } = await import('./epg.events')); + + const { ipcMain } = jest.requireMock('electron'); + (ipcMain.handle as jest.Mock).mockReset(); + (ipcMain.handle as jest.Mock).mockImplementation( + ( + channel: string, + handler: (event: unknown, args: unknown) => Promise + ) => { + ipcHandlers.set(channel, handler); + } + ); + + EpgEvents.bootstrapEpgEvents(); + }); + + function invoke(channel: string, args: unknown): Promise { + const handler = ipcHandlers.get(channel); + if (!handler) { + throw new Error(`No IPC handler registered for "${channel}"`); + } + return handler({}, args); + } + + describe.each([ + { + channel: 'EPG_MAPPING_GET', + args: { channelKey: 'bbc.one.uk' }, + operation: () => getEpgMapping, + resolved: { + id: 1, + channelKey: 'bbc.one.uk', + epgChannelId: 'BBC.ONE.UK', + playlistId: null, + }, + fallback: null, + }, + { + channel: 'EPG_MAPPING_SET', + args: { channelKey: 'bbc.one.uk', epgChannelId: 'BBC.ONE.UK' }, + operation: () => setEpgMapping, + resolved: { success: true }, + fallback: { success: false }, + }, + { + channel: 'EPG_MAPPING_DELETE', + args: { channelKey: 'bbc.one.uk' }, + operation: () => deleteEpgMapping, + resolved: { success: true }, + fallback: { success: false }, + }, + { + channel: 'EPG_CHANNEL_SEARCH', + args: { searchTerm: 'bbc' }, + operation: () => searchEpgChannels, + resolved: [ + { id: 'BBC.ONE.UK', displayName: 'BBC One', iconUrl: null }, + ], + fallback: [], + }, + { + channel: 'EPG_MAPPING_GET_BATCH', + args: { channelKeys: ['bbc.one.uk'] }, + operation: () => getEpgMappingsBatch, + resolved: new Map([['bbc.one.uk', 'BBC.ONE.UK']]), + fallback: {}, + }, + ])('$channel', ({ channel, args, operation, resolved, fallback }) => { + it('returns the fallback when the underlying query rejects', async () => { + operation().mockRejectedValue(new Error('database is locked')); + + await expect(invoke(channel, args)).resolves.toEqual(fallback); + }); + + it('returns the fallback when opening the database rejects', async () => { + getDatabase.mockRejectedValue(new Error('no such file')); + + await expect(invoke(channel, args)).resolves.toEqual(fallback); + expect(operation()).not.toHaveBeenCalled(); + }); + + it('passes the resolved value through on success', async () => { + operation().mockResolvedValue(resolved); + + const result = await invoke(channel, args); + + expect(result).toEqual( + // The batch handler is the one that reshapes its result. + resolved instanceof Map + ? Object.fromEntries(resolved) + : resolved + ); + }); + }); + + it('short-circuits a blank search term without touching the database', async () => { + await expect( + invoke('EPG_CHANNEL_SEARCH', { searchTerm: ' ' }) + ).resolves.toEqual([]); + expect(getDatabase).not.toHaveBeenCalled(); + expect(searchEpgChannels).not.toHaveBeenCalled(); + }); + + it('short-circuits an empty batch without touching the database', async () => { + await expect( + invoke('EPG_MAPPING_GET_BATCH', { channelKeys: [] }) + ).resolves.toEqual({}); + expect(getDatabase).not.toHaveBeenCalled(); + expect(getEpgMappingsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.ts b/apps/electron-backend/src/app/events/epg-mapping.service.ts index 58a09e0ac..7b767ce0a 100644 --- a/apps/electron-backend/src/app/events/epg-mapping.service.ts +++ b/apps/electron-backend/src/app/events/epg-mapping.service.ts @@ -67,7 +67,7 @@ export async function handleGetEpgMapping( ): Promise { try { const db = await getDatabase(); - return getEpgMapping(db, channelKey); + return await getEpgMapping(db, channelKey); } catch { return null; } @@ -96,7 +96,7 @@ export async function handleSetEpgMapping( ): Promise<{ success: boolean }> { try { const db = await getDatabase(); - return setEpgMapping(db, channelKey, epgChannelId, playlistId); + return await setEpgMapping(db, channelKey, epgChannelId, playlistId); } catch { return { success: false }; } @@ -107,7 +107,7 @@ export async function handleDeleteEpgMapping( ): Promise<{ success: boolean }> { try { const db = await getDatabase(); - return deleteEpgMapping(db, channelKey); + return await deleteEpgMapping(db, channelKey); } catch { return { success: false }; } @@ -123,7 +123,7 @@ export async function handleSearchEpgChannels( try { const db = await getDatabase(); - return searchEpgChannels(db, searchTerm, limit); + return await searchEpgChannels(db, searchTerm, limit); } catch { return []; }