mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* docs(skills): design implementation synchronization * docs(skills): plan implementation synchronization * fix(release): filter internal notes from public body * docs(release): synchronize release workflow guidance * fix(stalker): normalize catalog series flags * fix(stalker): preserve progress with scoped episode IDs * fix(playback): expose strict position persistence * docs(stalker): record series position compatibility * test(skills): validate repository skill contracts * fix(database): keep SQL trace values private * docs(skills): refresh Nx and SQLite ownership * docs(skills): align provider and UI guidance * docs(skills): tighten validated guidance * docs(release): require exact release pushes * style(electron): remove trailing blank line * fix(ci): classify repository skills coverage
562 lines
22 KiB
TypeScript
562 lines
22 KiB
TypeScript
import { __databaseConnectionTestHooks } from './connection';
|
|
|
|
function compactSql(statement: string): string {
|
|
return statement.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function createdObjectNames(prefix: string, statements: readonly string[]) {
|
|
return statements
|
|
.map(compactSql)
|
|
.filter((statement) => statement.startsWith(prefix))
|
|
.map((statement) => {
|
|
const match = statement.match(
|
|
/^(?:CREATE\s+(?:UNIQUE\s+)?(?:VIRTUAL\s+)?(?:TABLE|INDEX|TRIGGER)\s+IF\s+NOT\s+EXISTS)\s+([^\s(]+)/i
|
|
);
|
|
return match?.[1] ?? null;
|
|
})
|
|
.filter((name): name is string => Boolean(name));
|
|
}
|
|
|
|
describe('database schema statements', () => {
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
const {
|
|
createTableStatements,
|
|
columnMigrationStatements,
|
|
indexMigrationStatements,
|
|
normalizeXtreamContentAddedEpochs,
|
|
ensureContentTitleFts,
|
|
} = __databaseConnectionTestHooks;
|
|
const ensureDownloadsPauseResumeSchema = (
|
|
__databaseConnectionTestHooks as unknown as {
|
|
ensureDownloadsPauseResumeSchema: (sqlite: {
|
|
prepare: (statement: string) => {
|
|
get?: () => unknown;
|
|
run?: (...args: unknown[]) => unknown;
|
|
};
|
|
transaction: (callback: () => void) => () => void;
|
|
}) => void;
|
|
}
|
|
).ensureDownloadsPauseResumeSchema;
|
|
|
|
it('records only the statement type for expanded main-process SQL', () => {
|
|
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
const secrets = [
|
|
'main-user-secret',
|
|
'main-password-secret',
|
|
'https://main-user:main-password@example.com/live?token=main-token-secret',
|
|
];
|
|
|
|
__databaseConnectionTestHooks.traceSqlStatement(
|
|
`UPDATE playlists SET username = '${secrets[0]}', password = '${secrets[1]}', url = '${secrets[2]}'`
|
|
);
|
|
|
|
const output = (console.log as jest.Mock).mock.calls.flat().join('\n');
|
|
expect(output).toContain('"statementType":"UPDATE"');
|
|
expect(output).not.toContain('UPDATE playlists');
|
|
for (const secret of secrets) {
|
|
expect(output).not.toContain(secret);
|
|
}
|
|
});
|
|
|
|
function createRebuildSqlite(legacyTableSql: string | undefined) {
|
|
const statements: string[] = [];
|
|
const transaction = jest.fn((callback: () => void) => callback);
|
|
const prepare = jest.fn((statement: string) => {
|
|
if (statement.includes('FROM sqlite_master')) {
|
|
return {
|
|
get: () =>
|
|
legacyTableSql === undefined
|
|
? undefined
|
|
: { sql: legacyTableSql },
|
|
};
|
|
}
|
|
return {
|
|
run: () => {
|
|
statements.push(compactSql(statement));
|
|
},
|
|
};
|
|
});
|
|
|
|
return { prepare, statements, transaction };
|
|
}
|
|
|
|
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
|
|
const schemaSql = createTableStatements.map(compactSql).join('\n');
|
|
|
|
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists');
|
|
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories');
|
|
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS content');
|
|
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_channels');
|
|
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS epg_programs');
|
|
expect(schemaSql).toContain(
|
|
'CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5'
|
|
);
|
|
expect(schemaSql).toContain("tokenize='trigram'");
|
|
expect(schemaSql).toContain(
|
|
'CREATE TABLE IF NOT EXISTS playback_positions'
|
|
);
|
|
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
|
|
expect(schemaSql).toContain('request_headers TEXT');
|
|
expect(schemaSql).toContain('resume_validator TEXT');
|
|
expect(schemaSql).toContain("'paused'");
|
|
expect(schemaSql).toContain(
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE TRIGGER IF NOT EXISTS epg_programs_ai'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE TRIGGER IF NOT EXISTS epg_programs_ad'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE TRIGGER IF NOT EXISTS epg_programs_au'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE TRIGGER IF NOT EXISTS content_title_fts_ai'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE TRIGGER IF NOT EXISTS content_title_fts_ad'
|
|
);
|
|
expect(schemaSql).toContain(
|
|
'CREATE TRIGGER IF NOT EXISTS content_title_fts_au'
|
|
);
|
|
});
|
|
|
|
it('keeps legacy column migrations idempotent ALTER TABLE statements', () => {
|
|
expect(columnMigrationStatements.length).toBeGreaterThan(0);
|
|
expect(
|
|
columnMigrationStatements
|
|
.map(compactSql)
|
|
.every((statement) => statement.startsWith('ALTER TABLE '))
|
|
).toBe(true);
|
|
expect(columnMigrationStatements.map(compactSql)).toEqual(
|
|
expect.arrayContaining([
|
|
'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0',
|
|
'ALTER TABLE playlists ADD COLUMN payload TEXT',
|
|
'ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT',
|
|
'ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT',
|
|
'ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT',
|
|
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0',
|
|
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
|
|
])
|
|
);
|
|
});
|
|
|
|
it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => {
|
|
expect(indexMigrationStatements.length).toBeGreaterThan(0);
|
|
expect(
|
|
indexMigrationStatements
|
|
.map(compactSql)
|
|
.every((statement) =>
|
|
/^CREATE (?:UNIQUE )?INDEX IF NOT EXISTS /i.test(statement)
|
|
)
|
|
).toBe(true);
|
|
expect(indexMigrationStatements.map(compactSql)).toEqual(
|
|
expect.arrayContaining([
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)',
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)',
|
|
'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)',
|
|
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)',
|
|
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)',
|
|
])
|
|
);
|
|
});
|
|
|
|
it('creates indexes for migrated EPG program columns only after column migrations run', () => {
|
|
const createSchemaSql = createTableStatements.map(compactSql);
|
|
|
|
expect(createSchemaSql).not.toContain(
|
|
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)'
|
|
);
|
|
expect(createSchemaSql).not.toContain(
|
|
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)'
|
|
);
|
|
expect(columnMigrationStatements.map(compactSql)).toContain(
|
|
'ALTER TABLE epg_programs ADD COLUMN source_url TEXT'
|
|
);
|
|
});
|
|
|
|
it('does not define duplicate fresh-install schema object names', () => {
|
|
const objectNames = [
|
|
...createdObjectNames(
|
|
'CREATE TABLE IF NOT EXISTS',
|
|
createTableStatements
|
|
),
|
|
...createdObjectNames(
|
|
'CREATE VIRTUAL TABLE IF NOT EXISTS',
|
|
createTableStatements
|
|
),
|
|
...createdObjectNames(
|
|
'CREATE INDEX IF NOT EXISTS',
|
|
createTableStatements
|
|
),
|
|
...createdObjectNames(
|
|
'CREATE UNIQUE INDEX IF NOT EXISTS',
|
|
createTableStatements
|
|
),
|
|
...createdObjectNames(
|
|
'CREATE TRIGGER IF NOT EXISTS',
|
|
createTableStatements
|
|
),
|
|
];
|
|
|
|
expect(objectNames).toHaveLength(new Set(objectNames).size);
|
|
});
|
|
|
|
it('runs the legacy millisecond Xtream timestamp normalization once', () => {
|
|
const selectGet = jest.fn().mockReturnValue(undefined);
|
|
const updateRun = jest.fn();
|
|
const stateRun = jest.fn();
|
|
const prepare = jest.fn((statement: string) => {
|
|
if (statement.includes('SELECT value FROM app_state')) {
|
|
return { get: selectGet };
|
|
}
|
|
if (statement.includes('UPDATE content')) {
|
|
return { run: updateRun };
|
|
}
|
|
if (statement.includes('INSERT INTO app_state')) {
|
|
return { run: stateRun };
|
|
}
|
|
|
|
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
|
});
|
|
const transaction = jest.fn((callback: () => void) => callback);
|
|
const sqlite = {
|
|
prepare,
|
|
transaction,
|
|
} as unknown as Parameters<typeof normalizeXtreamContentAddedEpochs>[0];
|
|
|
|
normalizeXtreamContentAddedEpochs(sqlite);
|
|
|
|
expect(transaction).toHaveBeenCalledTimes(1);
|
|
expect(updateRun).toHaveBeenCalledWith(10_000_000_000, 10_000_000_000);
|
|
expect(stateRun).toHaveBeenCalledWith(
|
|
'migration:xtream-content-added-epoch-seconds:v1'
|
|
);
|
|
});
|
|
|
|
it('skips legacy Xtream timestamp normalization after it has run', () => {
|
|
const selectGet = jest.fn().mockReturnValue({ value: 'done' });
|
|
const prepare = jest.fn((statement: string) => {
|
|
if (statement.includes('SELECT value FROM app_state')) {
|
|
return { get: selectGet };
|
|
}
|
|
|
|
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
|
});
|
|
const transaction = jest.fn((callback: () => void) => callback);
|
|
const sqlite = {
|
|
prepare,
|
|
transaction,
|
|
} as unknown as Parameters<typeof normalizeXtreamContentAddedEpochs>[0];
|
|
|
|
normalizeXtreamContentAddedEpochs(sqlite);
|
|
|
|
expect(transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rebuilds the content title FTS index once for existing content', () => {
|
|
const selectGet = jest.fn().mockReturnValue(undefined);
|
|
const rebuildRun = jest.fn();
|
|
const stateRun = jest.fn();
|
|
const prepare = jest.fn((statement: string) => {
|
|
if (statement.includes('SELECT value FROM app_state')) {
|
|
return { get: selectGet };
|
|
}
|
|
if (statement.includes('content_title_fts')) {
|
|
return { run: rebuildRun };
|
|
}
|
|
if (statement.includes('INSERT INTO app_state')) {
|
|
return { run: stateRun };
|
|
}
|
|
|
|
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
|
});
|
|
const transaction = jest.fn((callback: () => void) => callback);
|
|
const sqlite = {
|
|
prepare,
|
|
transaction,
|
|
} as unknown as Parameters<typeof ensureContentTitleFts>[0];
|
|
|
|
ensureContentTitleFts(sqlite);
|
|
|
|
expect(transaction).toHaveBeenCalledTimes(1);
|
|
expect(rebuildRun).toHaveBeenCalledTimes(1);
|
|
expect(stateRun).toHaveBeenCalledWith(
|
|
'migration:content-title-fts-trigram:v1'
|
|
);
|
|
});
|
|
|
|
it('skips content title FTS rebuild after it has run', () => {
|
|
const selectGet = jest.fn().mockReturnValue({ value: 'done' });
|
|
const prepare = jest.fn((statement: string) => {
|
|
if (statement.includes('SELECT value FROM app_state')) {
|
|
return { get: selectGet };
|
|
}
|
|
|
|
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
|
});
|
|
const transaction = jest.fn((callback: () => void) => callback);
|
|
const sqlite = {
|
|
prepare,
|
|
transaction,
|
|
} as unknown as Parameters<typeof ensureContentTitleFts>[0];
|
|
|
|
ensureContentTitleFts(sqlite);
|
|
|
|
expect(transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('backfills the content title FTS index before content-mutating migrations', () => {
|
|
const callOrder: string[] = [];
|
|
const runMigrations = (
|
|
__databaseConnectionTestHooks as unknown as {
|
|
runMigrations: (sqlite: {
|
|
exec: (statement: string) => void;
|
|
prepare: (statement: string) => {
|
|
all?: () => unknown[];
|
|
get?: (...args: unknown[]) => unknown;
|
|
run?: (...args: unknown[]) => unknown;
|
|
};
|
|
transaction: (callback: () => void) => () => void;
|
|
}) => void;
|
|
}
|
|
).runMigrations;
|
|
const sqlite = {
|
|
exec: jest.fn(),
|
|
prepare: jest.fn((statement: string) => {
|
|
if (statement.includes('SELECT value FROM app_state')) {
|
|
return {
|
|
get: (): undefined => {
|
|
return undefined;
|
|
},
|
|
};
|
|
}
|
|
if (
|
|
statement.includes(
|
|
'INSERT INTO content_title_fts(content_title_fts)'
|
|
)
|
|
) {
|
|
return {
|
|
run: () => {
|
|
callOrder.push('fts-rebuild');
|
|
},
|
|
};
|
|
}
|
|
if (statement.includes('INSERT INTO app_state')) {
|
|
return { run: jest.fn() };
|
|
}
|
|
if (statement.includes('UPDATE content')) {
|
|
return {
|
|
run: () => {
|
|
callOrder.push('content-update');
|
|
},
|
|
};
|
|
}
|
|
if (statement.includes('DELETE FROM content')) {
|
|
return {
|
|
run: () => {
|
|
callOrder.push('content-delete');
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
all: () => [],
|
|
get: (): undefined => undefined,
|
|
run: jest.fn(),
|
|
};
|
|
}),
|
|
transaction: jest.fn((callback: () => void) => callback),
|
|
};
|
|
|
|
runMigrations(sqlite);
|
|
|
|
expect(callOrder).toEqual(['fts-rebuild', 'content-update']);
|
|
expect(sqlite.prepare).toHaveBeenCalledWith(
|
|
expect.stringContaining('UPDATE content')
|
|
);
|
|
});
|
|
|
|
it('backfills migrated EPG program source URLs after creating scoped EPG indexes', () => {
|
|
const callOrder: string[] = [];
|
|
const runMigrations = (
|
|
__databaseConnectionTestHooks as unknown as {
|
|
runMigrations: (sqlite: {
|
|
exec: (statement: string) => void;
|
|
prepare: (statement: string) => {
|
|
all?: () => unknown[];
|
|
get?: (...args: unknown[]) => unknown;
|
|
run?: (...args: unknown[]) => unknown;
|
|
};
|
|
transaction: (callback: () => void) => () => void;
|
|
}) => void;
|
|
}
|
|
).runMigrations;
|
|
const sqlite = {
|
|
exec: jest.fn((statement: string) => {
|
|
if (statement.includes('idx_epg_programs_source')) {
|
|
callOrder.push('epg-source-index');
|
|
}
|
|
}),
|
|
prepare: jest.fn((statement: string) => {
|
|
if (statement.includes('SELECT value FROM app_state')) {
|
|
return {
|
|
get: (): undefined => undefined,
|
|
};
|
|
}
|
|
if (statement.includes('UPDATE epg_programs')) {
|
|
return {
|
|
run: () => {
|
|
callOrder.push('epg-source-backfill');
|
|
},
|
|
};
|
|
}
|
|
if (statement.includes('INSERT INTO app_state')) {
|
|
return { run: jest.fn() };
|
|
}
|
|
|
|
return {
|
|
all: () => [],
|
|
get: (): undefined => undefined,
|
|
run: jest.fn(),
|
|
};
|
|
}),
|
|
transaction: jest.fn((callback: () => void) => callback),
|
|
};
|
|
|
|
runMigrations(sqlite);
|
|
|
|
expect(callOrder).toContain('epg-source-backfill');
|
|
expect(callOrder).toContain('epg-source-index');
|
|
expect(callOrder.indexOf('epg-source-index')).toBeLessThan(
|
|
callOrder.indexOf('epg-source-backfill')
|
|
);
|
|
});
|
|
|
|
it('rebuilds a legacy downloads table without paused status or stored headers', () => {
|
|
const sqlite = createRebuildSqlite(
|
|
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
|
|
);
|
|
|
|
ensureDownloadsPauseResumeSchema(sqlite);
|
|
|
|
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
|
|
const { statements } = sqlite;
|
|
const renameIndex = statements.findIndex((statement) =>
|
|
statement.startsWith('ALTER TABLE downloads RENAME TO')
|
|
);
|
|
const createIndex = statements.findIndex((statement) =>
|
|
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
|
|
);
|
|
const copyIndex = statements.findIndex((statement) =>
|
|
statement.startsWith('INSERT INTO downloads')
|
|
);
|
|
const dropLegacyIndex = statements.findIndex((statement) =>
|
|
statement.startsWith('DROP TABLE downloads_pause_resume_legacy')
|
|
);
|
|
|
|
expect(renameIndex).toBeGreaterThanOrEqual(0);
|
|
expect(createIndex).toBeGreaterThan(renameIndex);
|
|
expect(copyIndex).toBeGreaterThan(createIndex);
|
|
expect(dropLegacyIndex).toBeGreaterThan(copyIndex);
|
|
// Indexes on the old table are dropped before the rename and
|
|
// recreated after the copy.
|
|
expect(
|
|
statements.filter((statement) =>
|
|
statement.startsWith('DROP INDEX IF EXISTS')
|
|
).length
|
|
).toBe(3);
|
|
expect(
|
|
statements
|
|
.slice(dropLegacyIndex + 1)
|
|
.filter((statement) => statement.includes('CREATE')).length
|
|
).toBe(3);
|
|
// The rebuilt table carries the new contract; legacy rows get NULL
|
|
// stored headers, and the copy never references resume_validator so
|
|
// it defaults to NULL.
|
|
expect(statements[createIndex]).toContain(`'paused'`);
|
|
expect(statements[createIndex]).toContain('request_headers TEXT');
|
|
expect(statements[createIndex]).toContain('resume_validator TEXT');
|
|
expect(statements[copyIndex]).toContain('NULL AS request_headers');
|
|
expect(statements[copyIndex]).not.toContain('resume_validator');
|
|
});
|
|
|
|
it('copies stored headers when only the paused status is missing', () => {
|
|
const sqlite = createRebuildSqlite(
|
|
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
|
|
);
|
|
|
|
ensureDownloadsPauseResumeSchema(sqlite);
|
|
|
|
const copy = sqlite.statements.find((statement) =>
|
|
statement.startsWith('INSERT INTO downloads')
|
|
);
|
|
expect(copy).toBeDefined();
|
|
expect(copy).not.toContain('NULL AS request_headers');
|
|
expect(copy).toContain('request_headers');
|
|
});
|
|
|
|
it('skips the downloads rebuild when the table already has the paused contract', () => {
|
|
const sqlite = createRebuildSqlite(
|
|
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))`
|
|
);
|
|
|
|
ensureDownloadsPauseResumeSchema(sqlite);
|
|
|
|
expect(sqlite.transaction).not.toHaveBeenCalled();
|
|
expect(sqlite.statements).toHaveLength(0);
|
|
});
|
|
|
|
it('backfills migrated EPG program source URLs in bounded batches', () => {
|
|
const { backfillEpgProgramSourceUrls } =
|
|
__databaseConnectionTestHooks;
|
|
let updateStatement = '';
|
|
const backfillRun = jest
|
|
.fn()
|
|
.mockReturnValueOnce({ changes: 50_000 })
|
|
.mockReturnValueOnce({ changes: 12 });
|
|
const stateRun = jest.fn();
|
|
const prepare = jest.fn((statement: string) => {
|
|
if (statement.includes('SELECT value FROM app_state')) {
|
|
return {
|
|
get: (): undefined => undefined,
|
|
};
|
|
}
|
|
if (statement.includes('UPDATE epg_programs')) {
|
|
updateStatement = compactSql(statement);
|
|
return {
|
|
run: backfillRun,
|
|
};
|
|
}
|
|
if (statement.includes('INSERT INTO app_state')) {
|
|
return { run: stateRun };
|
|
}
|
|
|
|
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
|
});
|
|
const transaction = jest.fn((callback: () => void) => callback);
|
|
const sqlite = {
|
|
prepare,
|
|
transaction,
|
|
} as unknown as Parameters<typeof backfillEpgProgramSourceUrls>[0];
|
|
|
|
backfillEpgProgramSourceUrls(sqlite);
|
|
|
|
expect(updateStatement).toContain('LIMIT 50000');
|
|
expect(backfillRun).toHaveBeenCalledTimes(2);
|
|
expect(stateRun).toHaveBeenCalledWith(
|
|
'migration:epg-program-source-url-backfill:v1'
|
|
);
|
|
});
|
|
});
|