Files
iptvnator/libs/shared/database/src/lib/connection-migrations.spec.ts
T
4grayandClaude Opus 5 b02d79805b fix(tmdb): a new series no longer matches its older, better-known namesake (#1648)
Metadata is looked up in the app's own language, so an unrelated older foreign
series can come back under exactly the same localized name as a recent
local-language one. `pickConfidentMatch` admitted the older row through the
series "premiered earlier" tolerance — portals report the running season's
year while TMDB reports the premiere — and then let `pickMostPopular` decide
across every admitted candidate, discarding the year evidence that had just
admitted them. The better-known show won on votes, and the newer series
rendered its poster, cast, genres and rating.

Rank admitted candidates by year evidence first (`yearEvidenceTier`: the
provider's exact year, then a year off by one, then the series tolerance) and
let popularity break ties only inside the strongest tier any candidate
reached. The tolerance stays — three of eight real lookups from one install
depend on it — but it is a last resort, not an equal. Measured over 400
Cyrillic series titles sampled from a real catalog, 20 normalized keys had a
same-titled older series and 16 of those were the more popular row.

The mirror case is accepted knowingly: a long-running show whose stated
season year happens to BE another same-titled show's premiere year now
resolves to the newer show. Only the older show's season air dates could
separate the two and a search response does not carry them, while that shape
needs three coincidences at once against one that needs none.

Search cache keys move to `|v4` with a matching startup cleanup, because a
positive row naming the wrong show stays fresh for 30 days.

Merged with `Build on windows x64` red: the checked-in Windows Embedded MPV
runtime pin points at an upstream release whose retention expired, so that
job fails repository-wide on a cold cache. Unrelated to this change; tracked
separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:18:56 +02:00

417 lines
15 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 cache cleanup', () => {
function deleteStatements(sqlite: SqliteHandle): string[] {
return (sqlite.prepare as jest.Mock).mock.calls
.map(([statement]) => compactSql(statement))
.filter((statement) =>
statement.includes('DELETE FROM tmdb_metadata')
);
}
it('deletes every retired key generation and records each 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(3);
expect(deleteRun).toHaveBeenCalledTimes(3);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v2-cache-cleanup:v1',
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
const [unversionedDelete, v2Delete, v3Delete] =
deleteStatements(sqlite);
expect(unversionedDelete).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
// Each generation deletes only its own rows: the v4 rows the resolver
// writes now, and the `id:`/`person:`/`badProviderId:` rows, survive.
expect(v2Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v2'");
expect(v2Delete).not.toContain('v3');
expect(v3Delete).toContain("WHERE lookup_key LIKE 'title:%|year:%|v3'");
expect(v3Delete).not.toContain('v4');
});
it('runs only the generations that have not completed yet', () => {
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
[
'SELECT value FROM app_state',
{
get: (key: unknown) =>
key ===
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
? { value: 'done' }
: undefined,
},
],
['INSERT INTO app_state', { run: markerRun }],
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(2);
expect(markerRun.mock.calls.map(([key]) => key)).toEqual([
'migration:tmdb-search-lookup-v3-cache-cleanup:v1',
'migration:tmdb-search-lookup-v4-cache-cleanup:v1',
]);
expect(deleteStatements(sqlite)).toEqual([
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v2'"),
expect.stringContaining("lookup_key LIKE 'title:%|year:%|v3'"),
]);
});
it('does nothing after every migration has completed', () => {
const { sqlite, prepare, transaction } = createSqliteMock([
completedMigrationStateRule,
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).not.toHaveBeenCalled();
// One marker read per retired generation, nothing else
expect(prepare).toHaveBeenCalledTimes(3);
});
});
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();
});
});