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,358 @@
const andMock = jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
}));
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
const descMock = jest.fn((value: unknown) => ({ kind: 'desc', value }));
const sqlMock = jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings: Array.from(strings),
values,
}));
jest.mock('drizzle-orm', () => ({
and: (...conditions: unknown[]) => andMock(...conditions),
desc: (value: unknown) => descMock(value),
eq: (left: unknown, right: unknown) => eqMock(left, right),
sql: (strings: TemplateStringsArray, ...values: unknown[]) =>
sqlMock(strings, ...values),
}));
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import {
clearAllPlaybackPositions,
clearPlaybackPosition,
getAllPlaybackPositions,
getPlaybackPosition,
getRecentPlaybackPositions,
getSeriesPlaybackPositions,
savePlaybackPosition,
} from './playback-position.operations';
type QueryMock = {
from: jest.Mock;
where: jest.Mock;
orderBy: jest.Mock;
limit: jest.Mock;
then: (
resolve: (value: unknown[]) => void,
reject: (reason: unknown) => void
) => Promise<void>;
};
function createDbMock(selectResultsByCall: unknown[][] = []) {
let selectIndex = 0;
const queries: QueryMock[] = [];
const select = jest.fn(() => {
const rows = selectResultsByCall[selectIndex] ?? [];
selectIndex += 1;
const query: QueryMock = {
from: jest.fn(),
where: jest.fn(),
orderBy: jest.fn(),
limit: jest.fn().mockResolvedValue(rows),
then: (resolve, reject) =>
Promise.resolve(rows).then(resolve, reject),
};
query.from.mockReturnValue(query);
query.where.mockReturnValue(query);
query.orderBy.mockReturnValue(query);
queries.push(query);
return query;
});
const insertValues = jest.fn().mockResolvedValue(undefined);
const insert = jest.fn().mockReturnValue({ values: insertValues });
const updateWhere = jest.fn().mockResolvedValue(undefined);
const updateSet = jest.fn().mockReturnValue({ where: updateWhere });
const update = jest.fn().mockReturnValue({ set: updateSet });
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const deleteFn = jest.fn().mockReturnValue({ where: deleteWhere });
return {
db: {
select,
insert,
update,
delete: deleteFn,
} as unknown as AppDatabase,
deleteFn,
deleteWhere,
insert,
insertValues,
queries,
select,
update,
updateSet,
updateWhere,
};
}
describe('playback-position.operations', () => {
beforeEach(() => {
andMock.mockClear();
descMock.mockClear();
eqMock.mockClear();
sqlMock.mockClear();
});
describe('savePlaybackPosition', () => {
it('creates a stalker placeholder playlist before inserting a new position', async () => {
const { db, insert, insertValues } = createDbMock([[], []]);
const result = await savePlaybackPosition(db, 'playlist-1', {
contentXtreamId: 500,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 3600,
});
expect(result).toEqual({ success: true });
expect(insert).toHaveBeenNthCalledWith(1, schema.playlists);
expect(insert).toHaveBeenNthCalledWith(
2,
schema.playbackPositions
);
expect(insertValues).toHaveBeenNthCalledWith(1, {
id: 'playlist-1',
name: 'Imported Playlist',
type: 'stalker',
});
expect(insertValues).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
playlistId: 'playlist-1',
contentXtreamId: 500,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 3600,
})
);
});
it('honors the provided playlist type when creating the missing playlist', async () => {
const { db, insertValues } = createDbMock([[], []]);
await savePlaybackPosition(db, 'playlist-xt', {
contentXtreamId: 7,
contentType: 'vod',
positionSeconds: 10,
playlistType: 'xtream',
});
expect(insertValues).toHaveBeenNthCalledWith(1, {
id: 'playlist-xt',
name: 'Imported Playlist',
type: 'xtream',
});
});
it('does not recreate a playlist that already exists', async () => {
const { db, insert } = createDbMock([[{ id: 'playlist-1' }], []]);
await savePlaybackPosition(db, 'playlist-1', {
contentXtreamId: 500,
contentType: 'vod',
positionSeconds: 120,
});
expect(insert).toHaveBeenCalledTimes(1);
expect(insert).toHaveBeenCalledWith(schema.playbackPositions);
});
it('updates the existing row instead of inserting a duplicate position', async () => {
const { db, insert, update, updateSet } = createDbMock([
[{ id: 'playlist-1' }],
[{ id: 33, positionSeconds: 15 }],
]);
const result = await savePlaybackPosition(db, 'playlist-1', {
contentXtreamId: 500,
contentType: 'episode',
seriesXtreamId: 42,
seasonNumber: 2,
episodeNumber: 5,
positionSeconds: 480,
});
expect(result).toEqual({ success: true });
expect(insert).not.toHaveBeenCalled();
expect(update).toHaveBeenCalledWith(schema.playbackPositions);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
seriesXtreamId: 42,
seasonNumber: 2,
episodeNumber: 5,
positionSeconds: 480,
})
);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.id,
33
);
});
it('stamps updatedAt with CURRENT_TIMESTAMP on every save', async () => {
const { db, insertValues } = createDbMock([[], []]);
await savePlaybackPosition(db, 'playlist-1', {
contentXtreamId: 500,
contentType: 'vod',
positionSeconds: 120,
});
expect(sqlMock).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']);
expect(insertValues).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
updatedAt: expect.objectContaining({ kind: 'sql' }),
})
);
});
});
describe('getPlaybackPosition', () => {
it('returns the matching row scoped by playlist, content, and type', async () => {
const row = {
id: 1,
playlistId: 'playlist-1',
contentXtreamId: 500,
positionSeconds: 99,
};
const { db } = createDbMock([[row]]);
const result = await getPlaybackPosition(
db,
'playlist-1',
500,
'vod'
);
expect(result).toEqual(row);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.playlistId,
'playlist-1'
);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.contentXtreamId,
500
);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.contentType,
'vod'
);
});
it('returns null when no position is stored', async () => {
const { db } = createDbMock([[]]);
await expect(
getPlaybackPosition(db, 'playlist-1', 999, 'episode')
).resolves.toBeNull();
});
});
describe('series and playlist queries', () => {
it('restricts series positions to episode rows for the series', async () => {
const rows = [{ id: 1 }, { id: 2 }];
const { db, queries } = createDbMock([rows]);
await expect(
getSeriesPlaybackPositions(db, 'playlist-1', 42)
).resolves.toEqual(rows);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.seriesXtreamId,
42
);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.contentType,
'episode'
);
expect(queries[0].limit).not.toHaveBeenCalled();
});
it('returns recent positions newest-first with the default limit of 20', async () => {
const rows = [{ id: 3 }];
const { db, queries } = createDbMock([rows]);
await expect(
getRecentPlaybackPositions(db, 'playlist-1')
).resolves.toEqual(rows);
expect(descMock).toHaveBeenCalledWith(
schema.playbackPositions.updatedAt
);
expect(queries[0].limit).toHaveBeenCalledWith(20);
});
it('passes a custom limit through to the recent positions query', async () => {
const { db, queries } = createDbMock([[]]);
await getRecentPlaybackPositions(db, 'playlist-1', 5);
expect(queries[0].limit).toHaveBeenCalledWith(5);
});
it('returns all playlist positions without ordering or limits', async () => {
const rows = [{ id: 1 }, { id: 2 }, { id: 3 }];
const { db, queries } = createDbMock([rows]);
await expect(
getAllPlaybackPositions(db, 'playlist-1')
).resolves.toEqual(rows);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.playlistId,
'playlist-1'
);
expect(queries[0].orderBy).not.toHaveBeenCalled();
expect(queries[0].limit).not.toHaveBeenCalled();
});
});
describe('clearing positions', () => {
it('clears every position of a playlist', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(
clearAllPlaybackPositions(db, 'playlist-1')
).resolves.toEqual({ success: true });
expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions);
expect(deleteWhere).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'eq' })
);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.playlistId,
'playlist-1'
);
});
it('clears a single content position scoped by playlist, content, and type', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(
clearPlaybackPosition(db, 'playlist-1', 500, 'vod')
).resolves.toEqual({ success: true });
expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions);
expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(3);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.contentXtreamId,
500
);
expect(eqMock).toHaveBeenCalledWith(
schema.playbackPositions.contentType,
'vod'
);
});
});
});
@@ -0,0 +1,351 @@
const andMock = jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
}));
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
const descMock = jest.fn((value: unknown) => ({ kind: 'desc', value }));
const inArrayMock = jest.fn((left: unknown, values: unknown[]) => ({
kind: 'inArray',
left,
values,
}));
const sqlMock = Object.assign(
jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings: Array.from(strings),
values,
})),
{
placeholder: jest.fn((name: string) => ({
kind: 'placeholder',
name,
})),
}
);
jest.mock('drizzle-orm', () => ({
and: (...conditions: unknown[]) => andMock(...conditions),
desc: (value: unknown) => descMock(value),
eq: (left: unknown, right: unknown) => eqMock(left, right),
inArray: (left: unknown, values: unknown[]) => inArrayMock(left, values),
sql: sqlMock,
}));
jest.mock('./content-backdrop.operations', () => ({
persistContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
}));
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
import {
addRecentItem,
clearPlaylistRecentItems,
clearRecentlyViewed,
getRecentItems,
getRecentlyViewed,
removeRecentItem,
removeRecentItemsBatch,
} from './recently-viewed.operations';
type QueryMock = {
from: jest.Mock;
innerJoin: jest.Mock;
where: jest.Mock;
orderBy: jest.Mock;
limit: jest.Mock;
then: (
resolve: (value: unknown[]) => void,
reject: (reason: unknown) => void
) => Promise<void>;
};
function createDbMock(selectResultsByCall: unknown[][] = []) {
let selectIndex = 0;
const queries: QueryMock[] = [];
const select = jest.fn(() => {
const rows = selectResultsByCall[selectIndex] ?? [];
selectIndex += 1;
const query: QueryMock = {
from: jest.fn(),
innerJoin: jest.fn(),
where: jest.fn(),
orderBy: jest.fn(),
limit: jest.fn().mockResolvedValue(rows),
then: (resolve, reject) =>
Promise.resolve(rows).then(resolve, reject),
};
query.from.mockReturnValue(query);
query.innerJoin.mockReturnValue(query);
query.where.mockReturnValue(query);
query.orderBy.mockReturnValue(query);
queries.push(query);
return query;
});
const insertValues = jest.fn().mockResolvedValue(undefined);
const insert = jest.fn().mockReturnValue({ values: insertValues });
const updateWhere = jest.fn().mockResolvedValue(undefined);
const updateSet = jest.fn().mockReturnValue({ where: updateWhere });
const update = jest.fn().mockReturnValue({ set: updateSet });
const deleteExecute = jest.fn().mockResolvedValue(undefined);
const deletePrepare = jest
.fn()
.mockReturnValue({ execute: deleteExecute });
const deleteResult = {
prepare: deletePrepare,
then: (
resolve: (value: unknown) => void,
reject: (reason: unknown) => void
) => Promise.resolve(undefined).then(resolve, reject),
};
const deleteWhere = jest.fn().mockReturnValue(deleteResult);
const deleteFn = jest.fn().mockReturnValue({
where: deleteWhere,
then: (
resolve: (value: unknown) => void,
reject: (reason: unknown) => void
) => Promise.resolve(undefined).then(resolve, reject),
});
const transaction = jest.fn((callback: () => unknown) => {
const result = callback();
return Promise.resolve(result);
});
return {
db: {
select,
insert,
update,
delete: deleteFn,
transaction,
} as unknown as AppDatabase,
deleteExecute,
deleteFn,
deletePrepare,
deleteWhere,
insert,
insertValues,
queries,
select,
transaction,
update,
updateSet,
};
}
describe('recently-viewed.operations', () => {
beforeEach(() => {
andMock.mockClear();
descMock.mockClear();
eqMock.mockClear();
inArrayMock.mockClear();
sqlMock.mockClear();
sqlMock.placeholder.mockClear();
(persistContentBackdropIfMissing as jest.Mock).mockClear();
});
describe('reading recent items', () => {
it('returns the global history newest-first capped at 100 entries', async () => {
const rows = [
{ id: 2, title: 'Newest', viewed_at: '2026-07-02 10:00:00' },
{ id: 1, title: 'Older', viewed_at: '2026-07-01 10:00:00' },
];
const { db, queries } = createDbMock([rows]);
await expect(getRecentlyViewed(db)).resolves.toEqual(rows);
expect(descMock).toHaveBeenCalledWith(
schema.recentlyViewed.viewedAt
);
expect(queries[0].orderBy).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'desc' })
);
expect(queries[0].limit).toHaveBeenCalledWith(100);
expect(queries[0].innerJoin).toHaveBeenCalledTimes(3);
});
it('scopes playlist history to the playlist, newest-first with a 100 cap', async () => {
const rows = [{ id: 5, title: 'Recent Movie' }];
const { db, queries } = createDbMock([rows]);
await expect(getRecentItems(db, 'playlist-1')).resolves.toEqual(
rows
);
expect(eqMock).toHaveBeenCalledWith(
schema.recentlyViewed.playlistId,
'playlist-1'
);
expect(descMock).toHaveBeenCalledWith(
schema.recentlyViewed.viewedAt
);
expect(queries[0].limit).toHaveBeenCalledWith(100);
});
});
describe('addRecentItem', () => {
it('inserts a new history entry on first view and persists the backdrop', async () => {
const { db, insert, insertValues, update } = createDbMock([[]]);
await expect(
addRecentItem(db, 42, 'playlist-1', {
backdropUrl: 'https://example.com/backdrop.jpg',
})
).resolves.toEqual({ success: true });
expect(insert).toHaveBeenCalledWith(schema.recentlyViewed);
expect(insertValues).toHaveBeenCalledWith({
contentId: 42,
playlistId: 'playlist-1',
});
expect(update).not.toHaveBeenCalled();
expect(persistContentBackdropIfMissing).toHaveBeenCalledWith(
db,
42,
'https://example.com/backdrop.jpg'
);
});
it('refreshes viewedAt for an already-tracked item instead of duplicating it', async () => {
const { db, insert, update, updateSet } = createDbMock([
[{ id: 9, contentId: 42, playlistId: 'playlist-1' }],
]);
await expect(
addRecentItem(db, 42, 'playlist-1')
).resolves.toEqual({ success: true });
expect(insert).not.toHaveBeenCalled();
expect(update).toHaveBeenCalledWith(schema.recentlyViewed);
expect(updateSet).toHaveBeenCalledWith({
viewedAt: expect.objectContaining({ kind: 'sql' }),
});
expect(sqlMock).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']);
expect(eqMock).toHaveBeenCalledWith(
schema.recentlyViewed.contentId,
42
);
expect(eqMock).toHaveBeenCalledWith(
schema.recentlyViewed.playlistId,
'playlist-1'
);
expect(persistContentBackdropIfMissing).toHaveBeenCalledWith(
db,
42,
undefined
);
});
});
describe('clearing history', () => {
it('wipes the whole history table for the global clear', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(clearRecentlyViewed(db)).resolves.toEqual({
success: true,
});
expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed);
expect(deleteWhere).not.toHaveBeenCalled();
});
it('deletes only entries belonging to the playlist content ids', async () => {
const { db, deleteFn, deleteWhere } = createDbMock([
[{ id: 11 }, { id: 12 }],
]);
await expect(
clearPlaylistRecentItems(db, 'playlist-1')
).resolves.toEqual({ success: true });
expect(eqMock).toHaveBeenCalledWith(
schema.categories.playlistId,
'playlist-1'
);
expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed);
expect(inArrayMock).toHaveBeenCalledWith(
schema.recentlyViewed.contentId,
[11, 12]
);
expect(deleteWhere).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'inArray' })
);
});
it('skips the delete entirely when the playlist has no content', async () => {
const { db, deleteFn } = createDbMock([[]]);
await expect(
clearPlaylistRecentItems(db, 'playlist-empty')
).resolves.toEqual({ success: true });
expect(deleteFn).not.toHaveBeenCalled();
});
it('removes a single entry scoped by content and playlist', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(
removeRecentItem(db, 42, 'playlist-1')
).resolves.toEqual({ success: true });
expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed);
expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(2);
expect(eqMock).toHaveBeenCalledWith(
schema.recentlyViewed.contentId,
42
);
expect(eqMock).toHaveBeenCalledWith(
schema.recentlyViewed.playlistId,
'playlist-1'
);
});
});
describe('removeRecentItemsBatch', () => {
it('returns a zero count without touching the database for empty input', async () => {
const { db, deleteFn, transaction } = createDbMock();
await expect(removeRecentItemsBatch(db, [])).resolves.toEqual({
success: true,
count: 0,
});
expect(deleteFn).not.toHaveBeenCalled();
expect(transaction).not.toHaveBeenCalled();
});
it('executes one prepared placeholder delete per item inside a transaction', async () => {
const { db, deleteExecute, deletePrepare, transaction } =
createDbMock();
await expect(
removeRecentItemsBatch(db, [
{ contentId: 1, playlistId: 'playlist-1' },
{ contentId: 2, playlistId: 'playlist-2' },
])
).resolves.toEqual({ success: true, count: 2 });
expect(deletePrepare).toHaveBeenCalledTimes(1);
expect(sqlMock.placeholder).toHaveBeenCalledWith('contentId');
expect(sqlMock.placeholder).toHaveBeenCalledWith('playlistId');
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteExecute).toHaveBeenNthCalledWith(1, {
contentId: 1,
playlistId: 'playlist-1',
});
expect(deleteExecute).toHaveBeenNthCalledWith(2, {
contentId: 2,
playlistId: 'playlist-2',
});
});
});
});
@@ -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);
});
});