test: close coverage gaps in persistence, portal stores, EPG, players, and E2E (#1124)

* test(db): cover playback positions, recently viewed, and connection migrations

Add specs for the previously untested persistence paths: playback-position
and recently-viewed operations (upsert/dedup/ordering/scoped deletes),
shared-database path-utils, createTables and the tolerant column/index
migrations incl. Xtream cache deduplication. Exposes createTables through
the existing __databaseConnectionTestHooks object.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(xtream): cover the Electron DB-first data source and favorites guards

Add specs for electron-xtream-data-source (DB-hit vs cold-cache paths,
concurrent request dedup, error propagation, full method delegation) and
extend the favorites feature spec with the invalid-input and
content-not-found guard paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(stalker): cover favorites and recent store features

Add specs for with-stalker-favorites and with-stalker-recent: payload
normalization and id/title fallbacks, series-mode category forcing, meta
sync dispatches, snackbar/callback side effects, and error paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(epg): cover archive/summary utils and the EPG worker service

Add specs for the pure catch-up window and summary-progress helpers shared
by the EPG panels, and for epg-worker.service: in-flight dedup by URL,
double-settle guard, progress-aware timeouts, worker lifecycle and error
broadcasting. Also settle an interrupted fetch in epg.events.spec that
caused "Cannot log after tests are done" in longer runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(player): cover the VLC session service

Mirror the MPV session spec patterns for VLC: enqueue-command building and
RC response parsing, launch argv construction, instance reuse over the RC
socket, exit-code handling, and the retry-without-RC fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): add downloads page and EPG timeline interaction coverage

Downloads: empty state without sources, and a full lifecycle - authorize a
folder via a stubbed native dialog, download from a local server, verify
the completed item and file on disk, remove it from the UI. Timeline: zoom
changes block widths and the on-air info affordance opens the programme
dialog with the correct title and watch-live action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: split oversized specs to meet the file-size guideline

Address review feedback: extract shared drizzle mocks into
operations.test-helpers.ts and split the Electron data-source delegation
spec into delegation + user-data files. Pure reorganization - test counts
and assertions unchanged (29/13/10), all files now under 300 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): wait for DB readiness before opening the downloads page

On slow CI runners (macOS) the renderer can query SQLite while the DB
worker is still creating tables, leaving the downloads page on its
skeleton state forever. Poll a playlist read until it succeeds before
navigating on a cold profile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(build): exclude *.test-helpers.ts from the electron-backend app tsconfig

The new operations.test-helpers.ts uses jest globals and broke the webpack
build and tsc typecheck, which compile every non-spec file in the app.
Exclude the test-helpers pattern alongside the existing spec exclusions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-04 15:45:06 +02:00
1 parent 8eb0fe3261
commit 57ba1977a1
23 files changed
+3869 -1

No files matched your search

@@ -0,0 +1,210 @@
import { __databaseConnectionTestHooks } from './connection';
const {
columnMigrationStatements,
createTables,
createTableStatements,
indexMigrationStatements,
runMigrations,
} = __databaseConnectionTestHooks;
type SqliteHandle = Parameters<typeof runMigrations>[0];
function compactSql(statement: string): string {
return statement.replace(/\s+/g, ' ').trim();
}
type StatementHandler = {
all?: (...args: unknown[]) => unknown[];
get?: (...args: unknown[]) => unknown;
run?: jest.Mock;
};
type HandlerRule = [pattern: string, handler: StatementHandler];
function createSqliteMock(
rules: HandlerRule[],
exec: jest.Mock = jest.fn()
) {
const prepare = jest.fn((statement: string) => {
const compact = compactSql(statement);
const rule = rules.find(([pattern]) => compact.includes(pattern));
return {
all: jest.fn(() => []),
get: jest.fn(),
run: jest.fn(),
...(rule?.[1] ?? {}),
};
});
const transaction = jest.fn(
(callback: (...args: unknown[]) => unknown) => callback
);
return {
exec,
prepare,
sqlite: { exec, prepare, transaction } as unknown as SqliteHandle,
transaction,
};
}
const completedMigrationStateRule: HandlerRule = [
'SELECT value FROM app_state',
{ get: () => ({ value: 'done' }) },
];
describe('createTables', () => {
it('executes every fresh-install statement against the connection in order', () => {
const { exec, sqlite } = createSqliteMock([]);
createTables(sqlite);
expect(exec.mock.calls.map(([statement]) => statement)).toEqual([
...createTableStatements,
]);
});
});
describe('runMigrations error tolerance', () => {
let warnSpy: jest.SpyInstance;
beforeEach(() => {
warnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
warnSpy.mockRestore();
});
it('silently skips duplicate-column ALTER errors and still applies index migrations', () => {
const exec = jest.fn((statement: string) => {
if (compactSql(statement).startsWith('ALTER TABLE')) {
throw new Error('duplicate column name: hidden');
}
});
const { sqlite } = createSqliteMock(
[completedMigrationStateRule],
exec
);
runMigrations(sqlite);
const executedStatements = exec.mock.calls.map(([statement]) =>
compactSql(statement)
);
expect(executedStatements).toEqual([
...columnMigrationStatements.map(compactSql),
...indexMigrationStatements.map(compactSql),
]);
expect(warnSpy).not.toHaveBeenCalled();
});
it('warns and continues when a migration fails for another reason', () => {
const failingStatement = compactSql(columnMigrationStatements[0]);
const exec = jest.fn((statement: string) => {
if (compactSql(statement) === failingStatement) {
throw new Error('disk I/O error');
}
});
const { sqlite } = createSqliteMock(
[completedMigrationStateRule],
exec
);
runMigrations(sqlite);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Migration failed (continuing)')
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('disk I/O error')
);
expect(exec).toHaveBeenCalledTimes(
columnMigrationStatements.length + indexMigrationStatements.length
);
});
});
describe('runMigrations Xtream cache deduplication', () => {
it('re-points content to the canonical duplicate category before deleting the rest', () => {
const candidatesAll = jest.fn(() => [
{ id: 1, hidden: 0, contentCount: 10 },
{ id: 2, hidden: 1, contentCount: 0 },
]);
const updateContentRun = jest.fn();
const deleteCategoryRun = jest.fn();
const { sqlite } = createSqliteMock([
completedMigrationStateRule,
[
'FROM categories GROUP BY playlist_id, type, xtream_id',
{ all: () => [{ playlistId: 'p1', type: 'live', xtreamId: 5 }] },
],
['LEFT JOIN content', { all: candidatesAll }],
[
'UPDATE content SET category_id = ? WHERE category_id = ?',
{ run: updateContentRun },
],
['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }],
['FROM content GROUP BY category_id, type, xtream_id', { all: () => [] }],
]);
runMigrations(sqlite);
expect(candidatesAll).toHaveBeenCalledWith('p1', 'live', 5);
expect(updateContentRun).toHaveBeenCalledTimes(1);
expect(updateContentRun).toHaveBeenCalledWith(1, 2);
expect(deleteCategoryRun).toHaveBeenCalledTimes(1);
expect(deleteCategoryRun).toHaveBeenCalledWith(2);
});
it('moves favorites and history to the canonical content row before deleting duplicates', () => {
const moveFavoritesRun = jest.fn();
const deleteFavoritesRun = jest.fn();
const moveRecentlyViewedRun = jest.fn();
const deleteRecentlyViewedRun = jest.fn();
const deleteContentRun = jest.fn();
const { sqlite } = createSqliteMock([
completedMigrationStateRule,
[
'FROM categories GROUP BY playlist_id, type, xtream_id',
{ all: () => [] },
],
[
'FROM content GROUP BY category_id, type, xtream_id',
{
all: () => [
{ categoryId: 7, type: 'movie', xtreamId: 300 },
],
},
],
[
'SELECT id FROM content WHERE category_id = ?',
{ all: () => [{ id: 10 }, { id: 11 }] },
],
['INSERT INTO favorites', { run: moveFavoritesRun }],
[
'DELETE FROM favorites WHERE content_id = ?',
{ run: deleteFavoritesRun },
],
['INSERT INTO recently_viewed', { run: moveRecentlyViewedRun }],
[
'DELETE FROM recently_viewed WHERE content_id = ?',
{ run: deleteRecentlyViewedRun },
],
['DELETE FROM content WHERE id = ?', { run: deleteContentRun }],
]);
runMigrations(sqlite);
expect(moveFavoritesRun).toHaveBeenCalledWith(10, 11);
expect(deleteFavoritesRun).toHaveBeenCalledWith(11);
expect(moveRecentlyViewedRun).toHaveBeenCalledWith(10, 11);
expect(deleteRecentlyViewedRun).toHaveBeenCalledWith(11);
expect(deleteContentRun).toHaveBeenCalledTimes(1);
expect(deleteContentRun).toHaveBeenCalledWith(11);
});
});
@@ -342,6 +342,7 @@ const INDEX_MIGRATION_STATEMENTS = [
];
export const __databaseConnectionTestHooks = {
createTables,
createTableStatements: CREATE_TABLE_STATEMENTS,
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
@@ -0,0 +1,87 @@
const homedirMock = jest.fn<string, []>();
jest.mock('os', () => ({
...jest.requireActual('os'),
homedir: () => homedirMock(),
}));
import { existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
IPTVNATOR_E2E_DATA_DIR_ENV,
getElectronConfigDirectory,
getElectronUserDataPath,
getIptvnatorDataRoot,
getIptvnatorDatabaseDirectory,
getIptvnatorDatabasePath,
} from './path-utils';
describe('path-utils', () => {
let tempRoot: string;
let originalEnvValue: string | undefined;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-path-utils-'));
homedirMock.mockReturnValue(join(tempRoot, 'home'));
originalEnvValue = process.env[IPTVNATOR_E2E_DATA_DIR_ENV];
delete process.env[IPTVNATOR_E2E_DATA_DIR_ENV];
});
afterEach(() => {
if (originalEnvValue === undefined) {
delete process.env[IPTVNATOR_E2E_DATA_DIR_ENV];
} else {
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = originalEnvValue;
}
rmSync(tempRoot, { force: true, recursive: true });
});
it('uses and creates the E2E data dir override when the env variable is set', () => {
const e2eDataDir = join(tempRoot, 'e2e-data');
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir;
expect(getIptvnatorDataRoot()).toBe(e2eDataDir);
expect(existsSync(e2eDataDir)).toBe(true);
});
it('falls back to ~/.iptvnator when the env override is blank', () => {
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = ' ';
const expectedRoot = join(tempRoot, 'home', '.iptvnator');
expect(getIptvnatorDataRoot()).toBe(expectedRoot);
expect(existsSync(expectedRoot)).toBe(true);
});
it('places the databases directory and database file under the data root', () => {
const e2eDataDir = join(tempRoot, 'e2e-data');
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir;
const databaseDirectory = getIptvnatorDatabaseDirectory();
expect(databaseDirectory).toBe(join(e2eDataDir, 'databases'));
expect(existsSync(databaseDirectory)).toBe(true);
expect(getIptvnatorDatabasePath()).toBe(
join(e2eDataDir, 'databases', 'iptvnator.db')
);
});
it('returns null Electron user-data and config paths outside E2E runs', () => {
expect(getElectronUserDataPath()).toBeNull();
expect(getElectronConfigDirectory()).toBeNull();
});
it('creates Electron user-data and config directories under the E2E root', () => {
const e2eDataDir = join(tempRoot, 'e2e-data');
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir;
const userDataPath = getElectronUserDataPath();
const configDirectory = getElectronConfigDirectory();
expect(userDataPath).toBe(join(e2eDataDir, 'user-data'));
expect(configDirectory).toBe(join(e2eDataDir, 'config'));
expect(existsSync(userDataPath as string)).toBe(true);
expect(existsSync(configDirectory as string)).toBe(true);
});
});