mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user