test: split oversized specs to meet the file-size guideline

Address review feedback: extract shared drizzle mocks into
operations.test-helpers.ts and split the Electron data-source delegation
spec into delegation + user-data files. Pure reorganization - test counts
and assertions unchanged (29/13/10), all files now under 300 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 12:04:10 +02:00
1 parent d2b95442ed
commit f8fb03ef1e
5 files changed
+446 -462

No files matched your search

@@ -0,0 +1,165 @@
import type { AppDatabase } from '../database.types';
/**
* Shared drizzle-orm query-builder mocks for the operations specs.
* Jest keeps a separate module registry per test file, so every spec gets
* its own mock instances. `jest.mock()` calls cannot live here (Jest hoists
* them per file), so each spec registers the module mock itself:
*
* jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
*
* The `mock` prefix on the exports is required so the hoisted factory is
* allowed to reference them.
*/
export const mockDrizzle = {
and: jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
})),
desc: jest.fn((value: unknown) => ({ kind: 'desc', value })),
eq: jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
})),
inArray: jest.fn((left: unknown, values: unknown[]) => ({
kind: 'inArray',
left,
values,
})),
sql: Object.assign(
jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings: Array.from(strings),
values,
})),
{
placeholder: jest.fn((name: string) => ({
kind: 'placeholder',
name,
})),
}
),
};
export function mockDrizzleOrmModule() {
return {
and: (...conditions: unknown[]) => mockDrizzle.and(...conditions),
desc: (value: unknown) => mockDrizzle.desc(value),
eq: (left: unknown, right: unknown) => mockDrizzle.eq(left, right),
inArray: (left: unknown, values: unknown[]) =>
mockDrizzle.inArray(left, values),
sql: Object.assign(
(strings: TemplateStringsArray, ...values: unknown[]) =>
mockDrizzle.sql(strings, ...values),
{
placeholder: (name: string) =>
mockDrizzle.sql.placeholder(name),
}
),
};
}
export function resetDrizzleMocks() {
mockDrizzle.and.mockClear();
mockDrizzle.desc.mockClear();
mockDrizzle.eq.mockClear();
mockDrizzle.inArray.mockClear();
mockDrizzle.sql.mockClear();
mockDrizzle.sql.placeholder.mockClear();
}
export 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>;
};
/**
* Fluent AppDatabase mock. Each `db.select()` call consumes the next entry
* of `selectResultsByCall` as its resolved rows; every builder method
* returns the same chainable/thenable query object.
*/
export 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,
updateWhere,
};
}
@@ -1,29 +1,13 @@
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,
}));
import {
createDbMock,
mockDrizzle,
mockDrizzleOrmModule,
resetDrizzleMocks,
} from './operations.test-helpers';
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),
}));
jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import {
clearAllPlaybackPositions,
clearPlaybackPosition,
@@ -34,73 +18,9 @@ import {
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();
resetDrizzleMocks();
});
describe('savePlaybackPosition', () => {
@@ -193,7 +113,7 @@ describe('playback-position.operations', () => {
positionSeconds: 480,
})
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.id,
33
);
@@ -208,7 +128,7 @@ describe('playback-position.operations', () => {
positionSeconds: 120,
});
expect(sqlMock).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']);
expect(mockDrizzle.sql).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']);
expect(insertValues).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
@@ -236,15 +156,15 @@ describe('playback-position.operations', () => {
);
expect(result).toEqual(row);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.playlistId,
'playlist-1'
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.contentXtreamId,
500
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.contentType,
'vod'
);
@@ -268,11 +188,11 @@ describe('playback-position.operations', () => {
getSeriesPlaybackPositions(db, 'playlist-1', 42)
).resolves.toEqual(rows);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.seriesXtreamId,
42
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.contentType,
'episode'
);
@@ -287,7 +207,7 @@ describe('playback-position.operations', () => {
getRecentPlaybackPositions(db, 'playlist-1')
).resolves.toEqual(rows);
expect(descMock).toHaveBeenCalledWith(
expect(mockDrizzle.desc).toHaveBeenCalledWith(
schema.playbackPositions.updatedAt
);
expect(queries[0].limit).toHaveBeenCalledWith(20);
@@ -309,7 +229,7 @@ describe('playback-position.operations', () => {
getAllPlaybackPositions(db, 'playlist-1')
).resolves.toEqual(rows);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.playlistId,
'playlist-1'
);
@@ -330,7 +250,7 @@ describe('playback-position.operations', () => {
expect(deleteWhere).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'eq' })
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.playlistId,
'playlist-1'
);
@@ -345,11 +265,11 @@ describe('playback-position.operations', () => {
expect(deleteFn).toHaveBeenCalledWith(schema.playbackPositions);
expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(3);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.contentXtreamId,
500
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.playbackPositions.contentType,
'vod'
);
@@ -1,46 +1,17 @@
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,
})),
}
);
import {
createDbMock,
mockDrizzle,
mockDrizzleOrmModule,
resetDrizzleMocks,
} from './operations.test-helpers';
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('drizzle-orm', () => mockDrizzleOrmModule());
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,
@@ -52,103 +23,9 @@ import {
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();
resetDrizzleMocks();
(persistContentBackdropIfMissing as jest.Mock).mockClear();
});
@@ -162,7 +39,7 @@ describe('recently-viewed.operations', () => {
await expect(getRecentlyViewed(db)).resolves.toEqual(rows);
expect(descMock).toHaveBeenCalledWith(
expect(mockDrizzle.desc).toHaveBeenCalledWith(
schema.recentlyViewed.viewedAt
);
expect(queries[0].orderBy).toHaveBeenCalledWith(
@@ -180,11 +57,11 @@ describe('recently-viewed.operations', () => {
rows
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.recentlyViewed.playlistId,
'playlist-1'
);
expect(descMock).toHaveBeenCalledWith(
expect(mockDrizzle.desc).toHaveBeenCalledWith(
schema.recentlyViewed.viewedAt
);
expect(queries[0].limit).toHaveBeenCalledWith(100);
@@ -228,12 +105,12 @@ describe('recently-viewed.operations', () => {
expect(updateSet).toHaveBeenCalledWith({
viewedAt: expect.objectContaining({ kind: 'sql' }),
});
expect(sqlMock).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.sql).toHaveBeenCalledWith(['CURRENT_TIMESTAMP']);
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.recentlyViewed.contentId,
42
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.recentlyViewed.playlistId,
'playlist-1'
);
@@ -266,12 +143,12 @@ describe('recently-viewed.operations', () => {
clearPlaylistRecentItems(db, 'playlist-1')
).resolves.toEqual({ success: true });
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.categories.playlistId,
'playlist-1'
);
expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed);
expect(inArrayMock).toHaveBeenCalledWith(
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.recentlyViewed.contentId,
[11, 12]
);
@@ -299,11 +176,11 @@ describe('recently-viewed.operations', () => {
expect(deleteFn).toHaveBeenCalledWith(schema.recentlyViewed);
expect(deleteWhere.mock.calls[0][0].conditions).toHaveLength(2);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.recentlyViewed.contentId,
42
);
expect(eqMock).toHaveBeenCalledWith(
expect(mockDrizzle.eq).toHaveBeenCalledWith(
schema.recentlyViewed.playlistId,
'playlist-1'
);
@@ -335,8 +212,8 @@ describe('recently-viewed.operations', () => {
).resolves.toEqual({ success: true, count: 2 });
expect(deletePrepare).toHaveBeenCalledTimes(1);
expect(sqlMock.placeholder).toHaveBeenCalledWith('contentId');
expect(sqlMock.placeholder).toHaveBeenCalledWith('playlistId');
expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('contentId');
expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('playlistId');
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteExecute).toHaveBeenNthCalledWith(1, {
contentId: 1,
@@ -1,14 +1,14 @@
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import {
ElectronXtreamDataSourceHarness,
setupElectronXtreamDataSource,
} from './electron-xtream-data-source.test-helpers';
/**
* Delegation tests for ElectronXtreamDataSource: methods that forward to
* DatabaseService / PlaybackPositionService over the IPC boundary.
* The DB-first fetch/cache strategy is covered in
* electron-xtream-data-source.spec.ts.
* Delegation tests for ElectronXtreamDataSource: playlist, category, and
* content methods that forward to the DatabaseService over the IPC boundary.
* User-data delegation (favorites, recently viewed, playback positions,
* cleanup) is covered in electron-xtream-data-source.user-data.spec.ts and
* the DB-first fetch/cache strategy in electron-xtream-data-source.spec.ts.
*/
describe('ElectronXtreamDataSource (delegation)', () => {
let harness: ElectronXtreamDataSourceHarness;
@@ -174,218 +174,4 @@ describe('ElectronXtreamDataSource (delegation)', () => {
);
});
});
describe('favorites and recently viewed', () => {
it('delegates favorites operations to the DB', async () => {
harness.dbService.isFavorite.mockResolvedValue(true);
await harness.dataSource.addFavorite(
202,
playlistId,
'https://example.com/backdrop.png'
);
expect(harness.dbService.addToFavorites).toHaveBeenCalledWith(
202,
playlistId,
'https://example.com/backdrop.png'
);
await harness.dataSource.removeFavorite(202, playlistId);
expect(harness.dbService.removeFromFavorites).toHaveBeenCalledWith(
202,
playlistId
);
await expect(
harness.dataSource.isFavorite(202, playlistId)
).resolves.toBe(true);
await harness.dataSource.getFavorites(playlistId);
expect(harness.dbService.getFavorites).toHaveBeenCalledWith(
playlistId
);
});
it('delegates recently viewed operations to the DB', async () => {
await harness.dataSource.addRecentItem(202, playlistId);
expect(harness.dbService.addRecentItem).toHaveBeenCalledWith(
202,
playlistId,
undefined
);
await harness.dataSource.removeRecentItem(202, playlistId);
expect(harness.dbService.removeRecentItem).toHaveBeenCalledWith(
202,
playlistId
);
await harness.dataSource.getRecentItems(playlistId);
expect(harness.dbService.getRecentItems).toHaveBeenCalledWith(
playlistId
);
await harness.dataSource.clearRecentItems(playlistId);
expect(
harness.dbService.clearPlaylistRecentItems
).toHaveBeenCalledWith(playlistId);
});
it('delegates content lookup and backdrop backfill to the DB', async () => {
const item = { id: 1, title: 'Movie One', xtream_id: 202 };
harness.dbService.getContentByXtreamId.mockResolvedValue(item);
await expect(
harness.dataSource.getContentByXtreamId(
202,
playlistId,
'movie'
)
).resolves.toEqual(item);
expect(harness.dbService.getContentByXtreamId).toHaveBeenCalledWith(
202,
playlistId,
'movie'
);
await harness.dataSource.setContentBackdropIfMissing(
1,
playlistId,
'https://example.com/backdrop.png'
);
// playlistId is intentionally not forwarded for the Electron DB call
expect(
harness.dbService.setContentBackdropIfMissing
).toHaveBeenCalledWith(1, 'https://example.com/backdrop.png');
});
});
describe('playback positions', () => {
const position = {
contentXtreamId: 202,
contentType: 'vod',
position: 120,
duration: 3600,
} as unknown as PlaybackPositionData;
it('delegates playback position operations to the playback service', async () => {
harness.playbackService.getPlaybackPosition.mockResolvedValue(
position
);
harness.playbackService.getSeriesPlaybackPositions.mockResolvedValue(
[position]
);
harness.playbackService.getRecentPlaybackPositions.mockResolvedValue(
[position]
);
harness.playbackService.getAllPlaybackPositions.mockResolvedValue([
position,
]);
await harness.dataSource.savePlaybackPosition(playlistId, position);
expect(
harness.playbackService.savePlaybackPosition
).toHaveBeenCalledWith(playlistId, position);
await expect(
harness.dataSource.getPlaybackPosition(playlistId, 202, 'vod')
).resolves.toEqual(position);
await expect(
harness.dataSource.getSeriesPlaybackPositions(playlistId, 303)
).resolves.toEqual([position]);
expect(
harness.playbackService.getSeriesPlaybackPositions
).toHaveBeenCalledWith(playlistId, 303);
await expect(
harness.dataSource.getRecentPlaybackPositions(playlistId, 5)
).resolves.toEqual([position]);
expect(
harness.playbackService.getRecentPlaybackPositions
).toHaveBeenCalledWith(playlistId, 5);
await expect(
harness.dataSource.getAllPlaybackPositions(playlistId)
).resolves.toEqual([position]);
await harness.dataSource.clearPlaybackPosition(
playlistId,
202,
'vod'
);
expect(
harness.playbackService.clearPlaybackPosition
).toHaveBeenCalledWith(playlistId, 202, 'vod');
});
});
describe('cleanup operations', () => {
it('clearSessionCache is a no-op that touches no services', () => {
expect(
harness.dataSource.clearSessionCache(playlistId)
).toBeUndefined();
expect(
harness.dbService.deleteXtreamPlaylistContent
).not.toHaveBeenCalled();
});
it('combines DB restore data with playback positions on clearPlaylistContent', async () => {
const hidden = [{ categoryType: 'live', xtreamId: 5 }];
const favorites = [{ xtreamId: 202, type: 'movie' }];
const recentlyViewed = [{ xtreamId: 101, type: 'live' }];
const position = { contentXtreamId: 202 } as never;
harness.dbService.deleteXtreamPlaylistContent.mockResolvedValue({
hiddenCategories: hidden,
favorites,
recentlyViewed,
});
harness.playbackService.getAllPlaybackPositions.mockResolvedValue([
position,
]);
await expect(
harness.dataSource.clearPlaylistContent(playlistId)
).resolves.toEqual({
hiddenCategories: hidden,
favorites,
recentlyViewed,
playbackPositions: [position],
});
});
it('restores user data, then resets and replays playback positions', async () => {
const positionA = { contentXtreamId: 1 } as never;
const positionB = { contentXtreamId: 2 } as never;
const restoreState = {
hiddenCategories: [],
favorites: [{ xtreamId: 202, type: 'movie' }],
recentlyViewed: [{ xtreamId: 101, type: 'live' }],
playbackPositions: [positionA, positionB],
} as never;
const options = { operationId: 'op-1' };
await harness.dataSource.restoreUserData(
playlistId,
restoreState,
options
);
expect(
harness.dbService.restoreXtreamUserData
).toHaveBeenCalledWith(
playlistId,
[{ xtreamId: 202, type: 'movie' }],
[{ xtreamId: 101, type: 'live' }],
options
);
expect(
harness.playbackService.clearAllPlaybackPositions
).toHaveBeenCalledWith(playlistId);
expect(
harness.playbackService.savePlaybackPosition.mock.calls
).toEqual([
[playlistId, positionA],
[playlistId, positionB],
]);
});
});
});
@@ -0,0 +1,236 @@
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import {
ElectronXtreamDataSourceHarness,
setupElectronXtreamDataSource,
} from './electron-xtream-data-source.test-helpers';
/**
* Delegation tests for ElectronXtreamDataSource user data: favorites,
* recently viewed, playback positions, and cleanup/restore flows.
* Playlist/category/content delegation is covered in
* electron-xtream-data-source.delegation.spec.ts and the DB-first
* fetch/cache strategy in electron-xtream-data-source.spec.ts.
*/
describe('ElectronXtreamDataSource (user data delegation)', () => {
let harness: ElectronXtreamDataSourceHarness;
const playlistId = 'playlist-1';
beforeEach(() => {
harness = setupElectronXtreamDataSource();
});
describe('favorites and recently viewed', () => {
it('delegates favorites operations to the DB', async () => {
harness.dbService.isFavorite.mockResolvedValue(true);
await harness.dataSource.addFavorite(
202,
playlistId,
'https://example.com/backdrop.png'
);
expect(harness.dbService.addToFavorites).toHaveBeenCalledWith(
202,
playlistId,
'https://example.com/backdrop.png'
);
await harness.dataSource.removeFavorite(202, playlistId);
expect(harness.dbService.removeFromFavorites).toHaveBeenCalledWith(
202,
playlistId
);
await expect(
harness.dataSource.isFavorite(202, playlistId)
).resolves.toBe(true);
await harness.dataSource.getFavorites(playlistId);
expect(harness.dbService.getFavorites).toHaveBeenCalledWith(
playlistId
);
});
it('delegates recently viewed operations to the DB', async () => {
await harness.dataSource.addRecentItem(202, playlistId);
expect(harness.dbService.addRecentItem).toHaveBeenCalledWith(
202,
playlistId,
undefined
);
await harness.dataSource.removeRecentItem(202, playlistId);
expect(harness.dbService.removeRecentItem).toHaveBeenCalledWith(
202,
playlistId
);
await harness.dataSource.getRecentItems(playlistId);
expect(harness.dbService.getRecentItems).toHaveBeenCalledWith(
playlistId
);
await harness.dataSource.clearRecentItems(playlistId);
expect(
harness.dbService.clearPlaylistRecentItems
).toHaveBeenCalledWith(playlistId);
});
it('delegates content lookup and backdrop backfill to the DB', async () => {
const item = { id: 1, title: 'Movie One', xtream_id: 202 };
harness.dbService.getContentByXtreamId.mockResolvedValue(item);
await expect(
harness.dataSource.getContentByXtreamId(
202,
playlistId,
'movie'
)
).resolves.toEqual(item);
expect(harness.dbService.getContentByXtreamId).toHaveBeenCalledWith(
202,
playlistId,
'movie'
);
await harness.dataSource.setContentBackdropIfMissing(
1,
playlistId,
'https://example.com/backdrop.png'
);
// playlistId is intentionally not forwarded for the Electron DB call
expect(
harness.dbService.setContentBackdropIfMissing
).toHaveBeenCalledWith(1, 'https://example.com/backdrop.png');
});
});
describe('playback positions', () => {
const position = {
contentXtreamId: 202,
contentType: 'vod',
position: 120,
duration: 3600,
} as unknown as PlaybackPositionData;
it('delegates playback position operations to the playback service', async () => {
harness.playbackService.getPlaybackPosition.mockResolvedValue(
position
);
harness.playbackService.getSeriesPlaybackPositions.mockResolvedValue(
[position]
);
harness.playbackService.getRecentPlaybackPositions.mockResolvedValue(
[position]
);
harness.playbackService.getAllPlaybackPositions.mockResolvedValue([
position,
]);
await harness.dataSource.savePlaybackPosition(playlistId, position);
expect(
harness.playbackService.savePlaybackPosition
).toHaveBeenCalledWith(playlistId, position);
await expect(
harness.dataSource.getPlaybackPosition(playlistId, 202, 'vod')
).resolves.toEqual(position);
await expect(
harness.dataSource.getSeriesPlaybackPositions(playlistId, 303)
).resolves.toEqual([position]);
expect(
harness.playbackService.getSeriesPlaybackPositions
).toHaveBeenCalledWith(playlistId, 303);
await expect(
harness.dataSource.getRecentPlaybackPositions(playlistId, 5)
).resolves.toEqual([position]);
expect(
harness.playbackService.getRecentPlaybackPositions
).toHaveBeenCalledWith(playlistId, 5);
await expect(
harness.dataSource.getAllPlaybackPositions(playlistId)
).resolves.toEqual([position]);
await harness.dataSource.clearPlaybackPosition(
playlistId,
202,
'vod'
);
expect(
harness.playbackService.clearPlaybackPosition
).toHaveBeenCalledWith(playlistId, 202, 'vod');
});
});
describe('cleanup operations', () => {
it('clearSessionCache is a no-op that touches no services', () => {
expect(
harness.dataSource.clearSessionCache(playlistId)
).toBeUndefined();
expect(
harness.dbService.deleteXtreamPlaylistContent
).not.toHaveBeenCalled();
});
it('combines DB restore data with playback positions on clearPlaylistContent', async () => {
const hidden = [{ categoryType: 'live', xtreamId: 5 }];
const favorites = [{ xtreamId: 202, type: 'movie' }];
const recentlyViewed = [{ xtreamId: 101, type: 'live' }];
const position = { contentXtreamId: 202 } as never;
harness.dbService.deleteXtreamPlaylistContent.mockResolvedValue({
hiddenCategories: hidden,
favorites,
recentlyViewed,
});
harness.playbackService.getAllPlaybackPositions.mockResolvedValue([
position,
]);
await expect(
harness.dataSource.clearPlaylistContent(playlistId)
).resolves.toEqual({
hiddenCategories: hidden,
favorites,
recentlyViewed,
playbackPositions: [position],
});
});
it('restores user data, then resets and replays playback positions', async () => {
const positionA = { contentXtreamId: 1 } as never;
const positionB = { contentXtreamId: 2 } as never;
const restoreState = {
hiddenCategories: [],
favorites: [{ xtreamId: 202, type: 'movie' }],
recentlyViewed: [{ xtreamId: 101, type: 'live' }],
playbackPositions: [positionA, positionB],
} as never;
const options = { operationId: 'op-1' };
await harness.dataSource.restoreUserData(
playlistId,
restoreState,
options
);
expect(
harness.dbService.restoreXtreamUserData
).toHaveBeenCalledWith(
playlistId,
[{ xtreamId: 202, type: 'movie' }],
[{ xtreamId: 101, type: 'live' }],
options
);
expect(
harness.playbackService.clearAllPlaybackPositions
).toHaveBeenCalledWith(playlistId);
expect(
harness.playbackService.savePlaybackPosition.mock.calls
).toEqual([
[playlistId, positionA],
[playlistId, positionB],
]);
});
});
});