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