From 96568fc151bfc431191998eb8f0834528e19aad5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 11:12:11 +0200 Subject: [PATCH] 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 --- .../playback-position.operations.spec.ts | 358 ++++++++++++++++++ .../recently-viewed.operations.spec.ts | 351 +++++++++++++++++ .../src/lib/connection-migrations.spec.ts | 210 ++++++++++ libs/shared/database/src/lib/connection.ts | 1 + .../database/src/lib/path-utils.spec.ts | 87 +++++ 5 files changed, 1007 insertions(+) create mode 100644 apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts create mode 100644 libs/shared/database/src/lib/connection-migrations.spec.ts create mode 100644 libs/shared/database/src/lib/path-utils.spec.ts 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 new file mode 100644 index 000000000..88d05832b --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playback-position.operations.spec.ts @@ -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; +}; + +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' + ); + }); + }); +}); 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 new file mode 100644 index 000000000..6bd4db201 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -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; +}; + +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', + }); + }); + }); +}); diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts new file mode 100644 index 000000000..07a0485fc --- /dev/null +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -0,0 +1,210 @@ +import { __databaseConnectionTestHooks } from './connection'; + +const { + columnMigrationStatements, + createTables, + createTableStatements, + indexMigrationStatements, + runMigrations, +} = __databaseConnectionTestHooks; + +type SqliteHandle = Parameters[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); + }); +}); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index e4227a73f..a044a7cb4 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -342,6 +342,7 @@ const INDEX_MIGRATION_STATEMENTS = [ ]; export const __databaseConnectionTestHooks = { + createTables, createTableStatements: CREATE_TABLE_STATEMENTS, columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS, indexMigrationStatements: INDEX_MIGRATION_STATEMENTS, diff --git a/libs/shared/database/src/lib/path-utils.spec.ts b/libs/shared/database/src/lib/path-utils.spec.ts new file mode 100644 index 000000000..63de203e1 --- /dev/null +++ b/libs/shared/database/src/lib/path-utils.spec.ts @@ -0,0 +1,87 @@ +const homedirMock = jest.fn(); + +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); + }); +});