mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36: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
+358
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user