mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-17 06:36:15 -08:00
485 lines
17 KiB
TypeScript
485 lines
17 KiB
TypeScript
import { firstValueFrom, of } from 'rxjs';
|
|
import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
|
import { PlaylistsService, resolvePlaylistParser } from './playlists.service';
|
|
|
|
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
|
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
|
|
'm3u-playlists-stalker-metadata-v1';
|
|
|
|
describe('PlaylistsService', () => {
|
|
const testWindow = window as unknown as { electron?: unknown };
|
|
const originalElectron = testWindow.electron;
|
|
|
|
afterEach(() => {
|
|
testWindow.electron = originalElectron;
|
|
localStorage.removeItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG);
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
function createService(overrides: Record<string, unknown> = {}) {
|
|
const service = Object.create(
|
|
PlaylistsService.prototype
|
|
) as PlaylistsService;
|
|
|
|
Object.assign(service as object, {
|
|
dbService: {
|
|
clear: jest.fn(() => of(undefined)),
|
|
delete: jest.fn(() => of(undefined)),
|
|
getAll: jest.fn(() => of([])),
|
|
getByID: jest.fn(() => of(undefined)),
|
|
update: jest.fn(() => of(undefined)),
|
|
...overrides,
|
|
},
|
|
snackBar: {
|
|
open: jest.fn(),
|
|
},
|
|
translateService: {
|
|
instant: jest.fn((key: string) => key),
|
|
},
|
|
electronMigrationPromise: null,
|
|
indexedDbMigrationPromise: null,
|
|
playlistDeleteCleanups: [],
|
|
});
|
|
|
|
return service;
|
|
}
|
|
|
|
it('resolves the parser from a CommonJS default dynamic import shape', () => {
|
|
const parse = jest.fn();
|
|
|
|
expect(
|
|
resolvePlaylistParser({
|
|
default: { parse },
|
|
})
|
|
).toBe(parse);
|
|
});
|
|
|
|
it('runs registered cleanup hooks after deleting a browser playlist', async () => {
|
|
const cleanup = jest.fn().mockResolvedValue(undefined);
|
|
const deleteFromIndexedDb = jest.fn(() => of(undefined));
|
|
const service = createService({ delete: deleteFromIndexedDb });
|
|
Object.assign(service as object, {
|
|
playlistDeleteCleanups: [cleanup],
|
|
});
|
|
|
|
await expect(
|
|
firstValueFrom(service.deletePlaylist('playlist-1'))
|
|
).resolves.toEqual({ success: true });
|
|
|
|
expect(deleteFromIndexedDb).toHaveBeenCalledWith(
|
|
DbStores.Playlists,
|
|
'playlist-1'
|
|
);
|
|
expect(cleanup).toHaveBeenCalledWith('playlist-1');
|
|
});
|
|
|
|
it('migrates legacy Stalker portal flags in SQLite before returning playlists', async () => {
|
|
let storedPlaylists: Playlist[] = [
|
|
{
|
|
_id: 'stalker-1',
|
|
title: 'Legacy Stalker',
|
|
count: 0,
|
|
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
|
autoRefresh: false,
|
|
macAddress: '00:1A:79:AA:BB:CC',
|
|
portalUrl: 'http://example.com/stalker_portal/c/',
|
|
} as Playlist,
|
|
];
|
|
const appState = new Map<string, string>([
|
|
[SQLITE_PLAYLIST_MIGRATION_FLAG, '1'],
|
|
]);
|
|
const electron = {
|
|
dbGetAppPlaylists: jest.fn(async () => storedPlaylists),
|
|
dbGetAppState: jest.fn(
|
|
async (key: string) => appState.get(key) ?? null
|
|
),
|
|
dbSetAppState: jest.fn(async (key: string, value: string) => {
|
|
appState.set(key, value);
|
|
}),
|
|
dbUpsertAppPlaylist: jest.fn(),
|
|
dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => {
|
|
const updates = new Map(
|
|
playlists.map((playlist) => [playlist._id, playlist])
|
|
);
|
|
storedPlaylists = storedPlaylists.map(
|
|
(playlist) => updates.get(playlist._id) ?? playlist
|
|
);
|
|
}),
|
|
};
|
|
testWindow.electron = electron;
|
|
|
|
const service = createService();
|
|
|
|
await expect(
|
|
firstValueFrom(service.getAllPlaylists())
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
_id: 'stalker-1',
|
|
isFullStalkerPortal: true,
|
|
}),
|
|
]);
|
|
|
|
expect(electron.dbUpsertAppPlaylists).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
_id: 'stalker-1',
|
|
isFullStalkerPortal: true,
|
|
}),
|
|
]);
|
|
expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe(
|
|
'1'
|
|
);
|
|
});
|
|
|
|
it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => {
|
|
const electron = {
|
|
dbGetAppPlaylists: jest.fn(async () => []),
|
|
dbGetAppState: jest.fn(async (key: string) => {
|
|
if (
|
|
key === SQLITE_PLAYLIST_MIGRATION_FLAG ||
|
|
key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
|
|
) {
|
|
return '1';
|
|
}
|
|
|
|
return null;
|
|
}),
|
|
dbSetAppState: jest.fn(),
|
|
dbUpsertAppPlaylist: jest.fn(),
|
|
dbUpsertAppPlaylists: jest.fn(),
|
|
};
|
|
testWindow.electron = electron;
|
|
|
|
const service = createService();
|
|
|
|
await firstValueFrom(service.getAllPlaylists());
|
|
await firstValueFrom(service.getAllPlaylists());
|
|
|
|
expect(electron.dbUpsertAppPlaylists).not.toHaveBeenCalled();
|
|
expect(electron.dbSetAppState).not.toHaveBeenCalledWith(
|
|
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG,
|
|
'1'
|
|
);
|
|
});
|
|
|
|
it('migrates legacy Stalker portal flags in IndexedDB before returning full playlists', async () => {
|
|
let storedPlaylists: Playlist[] = [
|
|
{
|
|
_id: 'stalker-2',
|
|
title: 'IndexedDB Stalker',
|
|
count: 0,
|
|
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
|
autoRefresh: false,
|
|
macAddress: '00:1A:79:11:22:33',
|
|
portalUrl: 'http://example.com/portal/c/',
|
|
} as Playlist,
|
|
];
|
|
const dbService = {
|
|
getAll: jest.fn(() => of(storedPlaylists)),
|
|
update: jest.fn((_storeName: string, playlist: Playlist) => {
|
|
storedPlaylists = storedPlaylists.map((current) =>
|
|
current._id === playlist._id ? playlist : current
|
|
);
|
|
return of(playlist);
|
|
}),
|
|
};
|
|
testWindow.electron = undefined;
|
|
|
|
const service = createService(dbService);
|
|
|
|
await expect(firstValueFrom(service.getAllData())).resolves.toEqual([
|
|
expect.objectContaining({
|
|
_id: 'stalker-2',
|
|
isFullStalkerPortal: false,
|
|
}),
|
|
]);
|
|
|
|
expect(dbService.update).toHaveBeenCalledWith(
|
|
DbStores.Playlists,
|
|
expect.objectContaining({
|
|
_id: 'stalker-2',
|
|
isFullStalkerPortal: false,
|
|
})
|
|
);
|
|
expect(
|
|
localStorage.getItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)
|
|
).toBe('1');
|
|
});
|
|
|
|
it('persists hiddenGroupTitles in playlist meta updates', async () => {
|
|
const existingPlaylist: Playlist = {
|
|
_id: 'playlist-1',
|
|
title: 'Playlist One',
|
|
count: 2,
|
|
importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
autoRefresh: false,
|
|
hiddenGroupTitles: ['News'],
|
|
} as Playlist;
|
|
const dbService = {
|
|
getAll: jest.fn(() => of([])),
|
|
getByID: jest.fn(() => of(existingPlaylist)),
|
|
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
|
of(playlist)
|
|
),
|
|
};
|
|
testWindow.electron = undefined;
|
|
|
|
const service = createService(dbService);
|
|
|
|
await firstValueFrom(
|
|
service.updatePlaylistMeta({
|
|
_id: 'playlist-1',
|
|
hiddenGroupTitles: ['Movies', 'Sports'],
|
|
} as PlaylistMeta)
|
|
);
|
|
|
|
expect(dbService.update).toHaveBeenCalledWith(
|
|
DbStores.Playlists,
|
|
expect.objectContaining({
|
|
_id: 'playlist-1',
|
|
hiddenGroupTitles: ['Movies', 'Sports'],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('removes multiple recently-viewed identities in a single PWA write', async () => {
|
|
const existingPlaylist: Playlist = {
|
|
_id: 'playlist-3',
|
|
title: 'Playlist Three',
|
|
count: 3,
|
|
importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
autoRefresh: false,
|
|
recentlyViewed: [
|
|
{
|
|
source: 'm3u',
|
|
id: 'https://example.com/1.m3u8',
|
|
url: 'https://example.com/1.m3u8',
|
|
title: 'A',
|
|
category_id: 'live',
|
|
added_at: '2026-05-04T10:00:00.000Z',
|
|
},
|
|
{
|
|
source: 'm3u',
|
|
id: 'https://example.com/2.m3u8',
|
|
url: 'https://example.com/2.m3u8',
|
|
title: 'B',
|
|
category_id: 'live',
|
|
added_at: '2026-05-04T10:01:00.000Z',
|
|
},
|
|
{
|
|
source: 'm3u',
|
|
id: 'https://example.com/3.m3u8',
|
|
url: 'https://example.com/3.m3u8',
|
|
title: 'C',
|
|
category_id: 'live',
|
|
added_at: '2026-05-04T10:02:00.000Z',
|
|
},
|
|
],
|
|
} as Playlist;
|
|
const dbService = {
|
|
getAll: jest.fn(() => of([])),
|
|
getByID: jest.fn(() => of(existingPlaylist)),
|
|
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
|
of(playlist)
|
|
),
|
|
};
|
|
testWindow.electron = undefined;
|
|
|
|
const service = createService(dbService);
|
|
|
|
await firstValueFrom(
|
|
service.removeFromPlaylistRecentlyViewedBatch('playlist-3', [
|
|
'https://example.com/1.m3u8',
|
|
'https://example.com/2.m3u8',
|
|
])
|
|
);
|
|
|
|
expect(dbService.update).toHaveBeenCalledTimes(1);
|
|
expect(dbService.update).toHaveBeenCalledWith(
|
|
DbStores.Playlists,
|
|
expect.objectContaining({
|
|
_id: 'playlist-3',
|
|
recentlyViewed: [
|
|
expect.objectContaining({
|
|
url: 'https://example.com/3.m3u8',
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('removes multiple recently-viewed identities in a single Electron upsert', async () => {
|
|
const existingPlaylist: Playlist = {
|
|
_id: 'playlist-4',
|
|
title: 'Playlist Four',
|
|
count: 0,
|
|
importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
autoRefresh: false,
|
|
recentlyViewed: [
|
|
{
|
|
source: 'm3u',
|
|
id: 'https://example.com/a.m3u8',
|
|
url: 'https://example.com/a.m3u8',
|
|
title: 'A',
|
|
category_id: 'live',
|
|
added_at: '2026-05-04T10:00:00.000Z',
|
|
},
|
|
{
|
|
source: 'm3u',
|
|
id: 'https://example.com/b.m3u8',
|
|
url: 'https://example.com/b.m3u8',
|
|
title: 'B',
|
|
category_id: 'live',
|
|
added_at: '2026-05-04T10:01:00.000Z',
|
|
},
|
|
],
|
|
} as Playlist;
|
|
const electron = {
|
|
dbGetAppPlaylist: jest.fn(async () => existingPlaylist),
|
|
dbGetAppPlaylists: jest.fn(async () => []),
|
|
dbGetAppState: jest.fn(async (key: string) =>
|
|
key === SQLITE_PLAYLIST_MIGRATION_FLAG ? '1' : null
|
|
),
|
|
dbSetAppState: jest.fn(),
|
|
dbUpsertAppPlaylist: jest.fn(async () => undefined),
|
|
dbUpsertAppPlaylists: jest.fn(),
|
|
};
|
|
testWindow.electron = electron;
|
|
|
|
const service = createService();
|
|
|
|
await firstValueFrom(
|
|
service.removeFromPlaylistRecentlyViewedBatch('playlist-4', [
|
|
'https://example.com/a.m3u8',
|
|
'https://example.com/b.m3u8',
|
|
])
|
|
);
|
|
|
|
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(1);
|
|
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
_id: 'playlist-4',
|
|
recentlyViewed: [],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('short-circuits removeFromPlaylistRecentlyViewedBatch when identities is empty', async () => {
|
|
const existingPlaylist: Playlist = {
|
|
_id: 'playlist-5',
|
|
title: 'Playlist Five',
|
|
count: 0,
|
|
importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
autoRefresh: false,
|
|
recentlyViewed: [],
|
|
} as Playlist;
|
|
const dbService = {
|
|
getAll: jest.fn(() => of([])),
|
|
getByID: jest.fn(() => of(existingPlaylist)),
|
|
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
|
of(playlist)
|
|
),
|
|
};
|
|
testWindow.electron = undefined;
|
|
|
|
const service = createService(dbService);
|
|
|
|
await firstValueFrom(
|
|
service.removeFromPlaylistRecentlyViewedBatch('playlist-5', [])
|
|
);
|
|
|
|
expect(dbService.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => {
|
|
const existingPlaylist: Playlist = {
|
|
_id: 'playlist-2',
|
|
title: 'Playlist Two',
|
|
count: 1,
|
|
importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(),
|
|
autoRefresh: false,
|
|
hiddenGroupTitles: ['Radio-de'],
|
|
playlist: {
|
|
items: [{ id: 'channel-1' }],
|
|
},
|
|
} as Playlist;
|
|
const dbService = {
|
|
getAll: jest.fn(() => of([])),
|
|
getByID: jest.fn(() => of(existingPlaylist)),
|
|
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
|
of(playlist)
|
|
),
|
|
};
|
|
testWindow.electron = undefined;
|
|
|
|
const service = createService(dbService);
|
|
|
|
await firstValueFrom(
|
|
service.updatePlaylist('playlist-2', {
|
|
_id: 'playlist-2',
|
|
playlist: {
|
|
items: [],
|
|
},
|
|
} as Playlist)
|
|
);
|
|
|
|
expect(dbService.update).toHaveBeenCalledWith(
|
|
DbStores.Playlists,
|
|
expect.objectContaining({
|
|
_id: 'playlist-2',
|
|
hiddenGroupTitles: ['Radio-de'],
|
|
count: 0,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('keeps autoRefresh enabled when refreshing a playlist payload with the parser default disabled', async () => {
|
|
const existingPlaylist: Playlist = {
|
|
_id: 'playlist-3',
|
|
title: 'Playlist Three',
|
|
count: 1,
|
|
importDate: new Date('2026-04-12T00:00:00.000Z').toISOString(),
|
|
lastUsage: new Date('2026-04-12T00:00:00.000Z').toISOString(),
|
|
autoRefresh: true,
|
|
playlist: {
|
|
items: [{ id: 'channel-1' }],
|
|
},
|
|
} as Playlist;
|
|
const dbService = {
|
|
getAll: jest.fn(() => of([])),
|
|
getByID: jest.fn(() => of(existingPlaylist)),
|
|
update: jest.fn((_storeName: string, playlist: Playlist) =>
|
|
of(playlist)
|
|
),
|
|
};
|
|
testWindow.electron = undefined;
|
|
|
|
const service = createService(dbService);
|
|
|
|
await firstValueFrom(
|
|
service.updatePlaylist('playlist-3', {
|
|
_id: 'playlist-3',
|
|
autoRefresh: false,
|
|
playlist: {
|
|
items: [],
|
|
},
|
|
} as Playlist)
|
|
);
|
|
|
|
expect(dbService.update).toHaveBeenCalledWith(
|
|
DbStores.Playlists,
|
|
expect.objectContaining({
|
|
_id: 'playlist-3',
|
|
autoRefresh: true,
|
|
count: 0,
|
|
})
|
|
);
|
|
});
|
|
});
|