mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
fix(epg): make manual EPG mapping lookups actually fail soft (#1291)
The mapping handlers documented a fail-soft contract but only honored half
of it. Four of them returned the database operation's promise from inside
the `try` block without awaiting it, so the rejection escaped the `catch`:
the try block exits before the promise settles. A `getDatabase()` failure
was caught, but a SQLite error in the operation rejected the
`ipcMain.handle` promise, and the renderer call threw instead of receiving
`null` / `{success:false}` / `[]`.
Adding `await` in handleGetEpgMapping, handleSetEpgMapping,
handleDeleteEpgMapping and handleSearchEpgChannels closes the gap.
resolveChannelIds and handleGetEpgMappingsBatch already awaited correctly
and are unchanged.
This is pre-existing — the same shape predates the epg.events.ts split in
636545cb, which preserved semantics faithfully and inherited the bug.
Adds epg-mapping.service.spec.ts, the first coverage these handlers have
had: table-driven over all six functions against both failure modes, plus
the guard clauses and queryByResolvedChannelIds remapping. Verified to fail
on the old behavior — reverting the four awaits fails exactly the four
operation-rejection cases.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5932e71cb9
commit
19b592badb
2 files changed
+255
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: epg
|
||||
---
|
||||
|
||||
A database error while looking up manual EPG channel mappings no longer breaks
|
||||
the request that triggered it. Searching for a channel in the "Map EPG channel"
|
||||
dialog, or saving and removing a mapping, now degrades gracefully instead of
|
||||
failing outright.
|
||||
@@ -0,0 +1,246 @@
|
||||
const getDatabase = jest.fn();
|
||||
const getEpgMapping = jest.fn();
|
||||
const getEpgMappingsBatch = jest.fn();
|
||||
const setEpgMapping = jest.fn();
|
||||
const deleteEpgMapping = jest.fn();
|
||||
const searchEpgChannels = 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),
|
||||
}));
|
||||
|
||||
import {
|
||||
handleDeleteEpgMapping,
|
||||
handleGetEpgMapping,
|
||||
handleGetEpgMappingsBatch,
|
||||
handleSearchEpgChannels,
|
||||
handleSetEpgMapping,
|
||||
queryByResolvedChannelIds,
|
||||
resolveChannelIds,
|
||||
} from './epg-mapping.service';
|
||||
|
||||
const DB_HANDLE = { db: true };
|
||||
const DB_ERROR = new Error('getDatabase failed');
|
||||
const OP_ERROR = new Error('sqlite operation failed');
|
||||
|
||||
describe('epg-mapping.service', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
getDatabase.mockResolvedValue(DB_HANDLE);
|
||||
});
|
||||
|
||||
/**
|
||||
* The fail-soft contract: a mapping lookup must never take down an EPG IPC
|
||||
* request. Each entry is checked against BOTH failure modes — a rejecting
|
||||
* `getDatabase()` and a rejecting database operation.
|
||||
*/
|
||||
const failSoftCases: Array<{
|
||||
name: string;
|
||||
operation: jest.Mock;
|
||||
invoke: () => Promise<unknown>;
|
||||
fallback: unknown;
|
||||
}> = [
|
||||
{
|
||||
name: 'resolveChannelIds',
|
||||
operation: getEpgMappingsBatch,
|
||||
invoke: () => resolveChannelIds(['channel-a']),
|
||||
fallback: new Map(),
|
||||
},
|
||||
{
|
||||
name: 'handleGetEpgMapping',
|
||||
operation: getEpgMapping,
|
||||
invoke: () => handleGetEpgMapping('channel-a'),
|
||||
fallback: null,
|
||||
},
|
||||
{
|
||||
name: 'handleGetEpgMappingsBatch',
|
||||
operation: getEpgMappingsBatch,
|
||||
invoke: () => handleGetEpgMappingsBatch(['channel-a']),
|
||||
fallback: {},
|
||||
},
|
||||
{
|
||||
name: 'handleSetEpgMapping',
|
||||
operation: setEpgMapping,
|
||||
invoke: () => handleSetEpgMapping('channel-a', 'epg-a'),
|
||||
fallback: { success: false },
|
||||
},
|
||||
{
|
||||
name: 'handleDeleteEpgMapping',
|
||||
operation: deleteEpgMapping,
|
||||
invoke: () => handleDeleteEpgMapping('channel-a'),
|
||||
fallback: { success: false },
|
||||
},
|
||||
{
|
||||
name: 'handleSearchEpgChannels',
|
||||
operation: searchEpgChannels,
|
||||
invoke: () => handleSearchEpgChannels('bbc'),
|
||||
fallback: [],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, operation, invoke, fallback } of failSoftCases) {
|
||||
describe(`${name} fail-soft`, () => {
|
||||
it('returns the fallback when getDatabase() rejects', async () => {
|
||||
getDatabase.mockRejectedValue(DB_ERROR);
|
||||
|
||||
await expect(invoke()).resolves.toEqual(fallback);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the fallback when the database operation rejects', async () => {
|
||||
operation.mockRejectedValue(OP_ERROR);
|
||||
|
||||
await expect(invoke()).resolves.toEqual(fallback);
|
||||
expect(operation).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveChannelIds', () => {
|
||||
it('returns the mapping batch keyed by original channel id', async () => {
|
||||
const mappings = new Map([['channel-a', 'epg-a']]);
|
||||
getEpgMappingsBatch.mockResolvedValue(mappings);
|
||||
|
||||
await expect(resolveChannelIds(['channel-a'])).resolves.toEqual(
|
||||
mappings
|
||||
);
|
||||
expect(getEpgMappingsBatch).toHaveBeenCalledWith(DB_HANDLE, [
|
||||
'channel-a',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryByResolvedChannelIds', () => {
|
||||
it('queries mapped ids and remaps results onto the requested ids', async () => {
|
||||
getEpgMappingsBatch.mockResolvedValue(
|
||||
new Map([['channel-a', 'epg-a']])
|
||||
);
|
||||
const query = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ 'epg-a': 'A', 'channel-b': 'B' });
|
||||
|
||||
const result = await queryByResolvedChannelIds(
|
||||
['channel-a', 'channel-b'],
|
||||
query
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith(['epg-a', 'channel-b']);
|
||||
expect(result).toEqual({ 'channel-a': 'A', 'channel-b': 'B' });
|
||||
});
|
||||
|
||||
it('fills missing query results with null', async () => {
|
||||
getEpgMappingsBatch.mockResolvedValue(new Map());
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
|
||||
await expect(
|
||||
queryByResolvedChannelIds(['channel-a'], query)
|
||||
).resolves.toEqual({ 'channel-a': null });
|
||||
});
|
||||
|
||||
it('falls back to unmapped ids when the mapping lookup fails', async () => {
|
||||
getEpgMappingsBatch.mockRejectedValue(OP_ERROR);
|
||||
const query = jest.fn().mockResolvedValue({ 'channel-a': 'A' });
|
||||
|
||||
const result = await queryByResolvedChannelIds(
|
||||
['channel-a'],
|
||||
query
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith(['channel-a']);
|
||||
expect(result).toEqual({ 'channel-a': 'A' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGetEpgMappingsBatch', () => {
|
||||
it('converts the mapping batch into a plain record', async () => {
|
||||
getEpgMappingsBatch.mockResolvedValue(
|
||||
new Map([['channel-a', 'epg-a']])
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleGetEpgMappingsBatch(['channel-a'])
|
||||
).resolves.toEqual({ 'channel-a': 'epg-a' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty array', [] as string[]],
|
||||
['a non-array value', undefined as unknown as string[]],
|
||||
])(
|
||||
'short-circuits for %s without touching the database',
|
||||
async (_label, channelKeys) => {
|
||||
await expect(
|
||||
handleGetEpgMappingsBatch(channelKeys)
|
||||
).resolves.toEqual({});
|
||||
expect(getDatabase).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('handleSearchEpgChannels', () => {
|
||||
it('forwards the search term and limit to the database', async () => {
|
||||
const matches = [
|
||||
{ id: 'epg-a', displayName: 'BBC One', iconUrl: null },
|
||||
];
|
||||
searchEpgChannels.mockResolvedValue(matches);
|
||||
|
||||
await expect(handleSearchEpgChannels('bbc', 25)).resolves.toEqual(
|
||||
matches
|
||||
);
|
||||
expect(searchEpgChannels).toHaveBeenCalledWith(
|
||||
DB_HANDLE,
|
||||
'bbc',
|
||||
25
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an empty term', ''],
|
||||
['a whitespace-only term', ' '],
|
||||
['an undefined term', undefined as unknown as string],
|
||||
])(
|
||||
'short-circuits for %s without touching the database',
|
||||
async (_label, searchTerm) => {
|
||||
await expect(
|
||||
handleSearchEpgChannels(searchTerm)
|
||||
).resolves.toEqual([]);
|
||||
expect(getDatabase).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('mapping writes', () => {
|
||||
it('passes the optional playlist id through to setEpgMapping', async () => {
|
||||
setEpgMapping.mockResolvedValue({ success: true });
|
||||
|
||||
await expect(
|
||||
handleSetEpgMapping('channel-a', 'epg-a', 'playlist-1')
|
||||
).resolves.toEqual({ success: true });
|
||||
expect(setEpgMapping).toHaveBeenCalledWith(
|
||||
DB_HANDLE,
|
||||
'channel-a',
|
||||
'epg-a',
|
||||
'playlist-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a mapping by channel key', async () => {
|
||||
deleteEpgMapping.mockResolvedValue({ success: true });
|
||||
|
||||
await expect(handleDeleteEpgMapping('channel-a')).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(deleteEpgMapping).toHaveBeenCalledWith(
|
||||
DB_HANDLE,
|
||||
'channel-a'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user