fix(epg): await mapping queries so lookups fail soft (#1279)

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-27 08:43:52 +02:00
1 parent e2300bea11
commit 26331676f9
3 files changed
+187 -4

No files matched your search

+9
View File
@@ -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.
@@ -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<unknown>
>();
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<unknown>
) => {
ipcHandlers.set(channel, handler);
}
);
EpgEvents.bootstrapEpgEvents();
});
function invoke(channel: string, args: unknown): Promise<unknown> {
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();
});
});
@@ -67,7 +67,7 @@ export async function handleGetEpgMapping(
): Promise<EpgMappingRecord | null> {
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 [];
}