diff --git a/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts new file mode 100644 index 000000000..cf6b3c49f --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts @@ -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; +}; + +/** + * 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, + }; +} diff --git a/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts index 88d05832b..1a919641d 100644 --- a/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts @@ -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; -}; - -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' ); diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts index 6bd4db201..a1a151a8c 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -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; -}; - -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, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts index 79aa0f906..983a85c21 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts @@ -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], - ]); - }); - }); }); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts new file mode 100644 index 000000000..1b887d1b4 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -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], + ]); + }); + }); +});