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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 11:12:11 +02:00
1 parent f015ec1ca9
commit 96568fc151
5 files changed
+1007

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);
});
});