Files
iptvnator/libs/shared/database/src/lib/connection-migrations.spec.ts
T
4gray bad8a0991e feat(downloads): download completed Xtream catch-up programmes as TS (#1572)
* feat(epg): copy catch-up programme URLs without changing playback

* feat(downloads): save completed Xtream archive programmes as TS

* fix(epg): let newer archive copy requests supersede pending work

* fix(downloads): protect archive partials and independent submissions

* fix(downloads): verify archive identity through finalization

* fix(downloads): bound archive storage and capture cleanup entries

* fix(downloads): preserve archive ownership across failure paths

* fix(downloads): recover explicitly verified archive completions

* fix(downloads): journal archive promotion before publishing files

* fix(downloads): reset archive proof before an explicit restart

* fix(downloads): preserve archive recovery ownership and interruption

* fix(downloads): verify durable archive identity at resume open

* fix(downloads): fence archive commands during completion commit

* fix(downloads): persist archive ownership throughout its lifecycle

* fix(downloads): protect archive removal and missing-file recovery

* fix(downloads): journal private cleanup captures for recovery

* fix(downloads): journal active archive cleanup before removal

* fix(downloads): clean settled archives before deleting stale rows

* fix(downloads): preserve archive ownership on removal and resubmission

* fix(downloads): recover proven archive completions before retry

* fix(downloads): recover local archives before remote transfer checks

* test(downloads): resolve archive fixture from workspace root

* fix(downloads): distinguish reused archive inodes by creation time

* fix(downloads): bind fresh archive reservations to owned files

* fix(downloads): clean reservations when ownership writes fail

* fix(downloads): commit archive reservation and ownership atomically

* fix(downloads): retain captures until replacement restoration succeeds

* fix(downloads): require durable ownership before cleanup relocation

* fix(downloads): preserve 64-bit archive file identities on Windows

* refactor(release): keep capture fixture constants in their shared module

* fix(downloads): preserve the last link of captured foreign files

* fix(downloads): expose retained archive recovery files

* fix(downloads): keep recovery instructions open while copying
2026-09-08 20:33:05 +02:00

374 lines
13 KiB
TypeScript

import { __databaseConnectionTestHooks } from './connection';
const {
columnMigrationStatements,
createTables,
createTableStatements,
indexMigrationStatements,
runMigrations,
cleanupLegacyTmdbSearchCache,
upgradeContentTitleFtsTokenizer,
contentTitleFtsStatement,
} = __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' }) },
];
/** The live title index, already carrying the folding tokenizer. */
const foldedIndexRule: HandlerRule = [
"name = 'content_title_fts'",
{
get: () => ({
sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram remove_diacritics 1')",
}),
},
];
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, foldedIndexRule],
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, foldedIndexRule],
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('TMDB search lookup v2 cache cleanup', () => {
it('deletes legacy search rows and records the migration atomically', () => {
const deleteRun = jest.fn();
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
['SELECT value FROM app_state', { get: () => undefined }],
['DELETE FROM tmdb_metadata', { run: deleteRun }],
['INSERT INTO app_state', { run: markerRun }],
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledWith(
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
);
const deleteSql = compactSql(
(sqlite.prepare as jest.Mock).mock.calls.find(([statement]) =>
statement.includes('DELETE FROM tmdb_metadata')
)?.[0]
);
expect(deleteSql).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
});
it('does nothing after the migration has completed', () => {
const { sqlite, prepare, transaction } = createSqliteMock([
completedMigrationStateRule,
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).not.toHaveBeenCalled();
expect(prepare).toHaveBeenCalledTimes(1);
});
});
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);
});
});
/**
* The title index folds diacritics, or the cross-playlist matcher cannot see
* accented titles at all: it compares normalized titles ("amelie") against an
* index built from the raw one ("Amélie").
*/
describe('content title FTS tokenizer upgrade', () => {
it('asks for diacritic folding in the statement it creates', () => {
expect(compactSql(contentTitleFtsStatement(true))).toContain(
"tokenize='trigram remove_diacritics 1'"
);
expect(compactSql(contentTitleFtsStatement(false))).toContain(
"tokenize='trigram'"
);
});
it('recreates and rebuilds the index, then records the migration', () => {
const rebuild = { run: jest.fn() };
const marker = { run: jest.fn() };
const { sqlite, exec, transaction } = createSqliteMock([
['SELECT value FROM app_state', { get: () => undefined }],
['INSERT INTO content_title_fts(content_title_fts)', rebuild],
['INSERT INTO app_state', marker],
]);
upgradeContentTitleFtsTokenizer(sqlite);
// The tokenizer is fixed at CREATE time, so the table has to go.
const statements = exec.mock.calls.map(([sql]) => compactSql(sql));
expect(statements).toContainEqual(
expect.stringContaining('DROP TABLE IF EXISTS content_title_fts')
);
expect(statements).toContainEqual(
expect.stringContaining("tokenize='trigram remove_diacritics 1'")
);
expect(rebuild.run).toHaveBeenCalled();
expect(marker.run).toHaveBeenCalled();
// One transaction, so a rejected CREATE rolls the drop back and the
// working index survives.
expect(transaction).toHaveBeenCalled();
});
it('does nothing once the migration has completed', () => {
const { sqlite, exec } = createSqliteMock([
completedMigrationStateRule,
foldedIndexRule,
]);
upgradeContentTitleFtsTokenizer(sqlite);
expect(exec).not.toHaveBeenCalled();
});
it('rebuilds when the record says done but the index is not folded', () => {
const rebuild = { run: jest.fn() };
const { sqlite, exec } = createSqliteMock([
completedMigrationStateRule,
[
'SELECT sql FROM sqlite_master',
{
get: () => ({
sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram')",
}),
},
],
['INSERT INTO content_title_fts(content_title_fts)', rebuild],
]);
upgradeContentTitleFtsTokenizer(sqlite);
// `createTables` declares this table too, with the plain tokenizer, so
// the marker and the live table can disagree. Trusting the marker
// leaves a silently degraded index: discovery simply stops finding
// "Pokémon" for "pokemon", with nothing to show that it should have.
const statements = exec.mock.calls.map(([sql]) => compactSql(sql));
expect(statements).toContainEqual(
expect.stringContaining("tokenize='trigram remove_diacritics 1'")
);
expect(rebuild.run).toHaveBeenCalled();
});
it('leaves the index alone when the runtime rejects the tokenizer', () => {
const marker = { run: jest.fn() };
const exec = jest.fn((statement: string) => {
if (statement.includes('remove_diacritics')) {
throw new Error('unknown tokenizer option');
}
});
const { sqlite } = createSqliteMock(
[
['SELECT value FROM app_state', { get: () => undefined }],
['INSERT INTO app_state', marker],
],
exec
);
upgradeContentTitleFtsTokenizer(sqlite);
// The probe failed, so the real table was never dropped — and the
// migration is NOT marked done, so a newer SQLite retries it.
const statements = exec.mock.calls.map(([sql]) => compactSql(sql));
expect(statements).not.toContainEqual(
expect.stringContaining('DROP TABLE IF EXISTS content_title_fts')
);
expect(marker.run).not.toHaveBeenCalled();
});
});