mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
test(stalker): cover favorites and recent store features
Add specs for with-stalker-favorites and with-stalker-recent: payload normalization and id/title fallbacks, series-mode category forcing, meta sync dispatches, snackbar/callback side effects, and error paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3fcebe9fa
commit
463ddd077f
2 files changed
+401
No files matched your search
+192
@@ -0,0 +1,192 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import { withStalkerFavorites } from './with-stalker-favorites.feature';
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'portal-1',
|
||||
title: 'Demo Stalker',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: '2026-04-14T00:00:00.000Z',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestFavoritesStore = signalStore(
|
||||
withState<{
|
||||
currentPlaylist: PlaylistMeta | undefined;
|
||||
selectedContentType: StalkerContentType;
|
||||
}>({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedContentType: 'vod',
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
},
|
||||
setSelectedContentType(type: StalkerContentType) {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
})),
|
||||
withStalkerFavorites()
|
||||
);
|
||||
|
||||
describe('withStalkerFavorites', () => {
|
||||
let store: InstanceType<typeof TestFavoritesStore>;
|
||||
let playlistService: {
|
||||
addPortalFavorite: jest.Mock;
|
||||
removeFromPortalFavorites: jest.Mock;
|
||||
};
|
||||
let snackBar: { open: jest.Mock };
|
||||
let ngrxStore: { dispatch: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
playlistService = {
|
||||
addPortalFavorite: jest.fn(() =>
|
||||
of({ favorites: [{ id: '42', title: 'Movie Title' }] })
|
||||
),
|
||||
removeFromPortalFavorites: jest.fn(() =>
|
||||
of({ favorites: [] })
|
||||
),
|
||||
};
|
||||
snackBar = { open: jest.fn() };
|
||||
ngrxStore = { dispatch: jest.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestFavoritesStore,
|
||||
{ provide: PlaylistsService, useValue: playlistService },
|
||||
{ provide: MatSnackBar, useValue: snackBar },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: jest.fn((key: string) => key) },
|
||||
},
|
||||
{ provide: Store, useValue: ngrxStore },
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestFavoritesStore);
|
||||
});
|
||||
|
||||
describe('addToFavorites', () => {
|
||||
it('persists the favorite with a normalized payload and syncs playlist meta', () => {
|
||||
const onDone = jest.fn();
|
||||
|
||||
store.addToFavorites(
|
||||
{
|
||||
stream_id: 42,
|
||||
id: 'ignored-id',
|
||||
name: 'Movie Title',
|
||||
category_id: '17',
|
||||
},
|
||||
onDone
|
||||
);
|
||||
|
||||
expect(playlistService.addPortalFavorite).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
expect.objectContaining({
|
||||
id: 42,
|
||||
name: 'Movie Title',
|
||||
category_id: '17',
|
||||
added_at: expect.any(Number),
|
||||
})
|
||||
);
|
||||
expect(ngrxStore.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
favorites: [{ id: '42', title: 'Movie Title' }],
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'PORTALS.ADDED_TO_FAVORITES',
|
||||
undefined,
|
||||
{ duration: 1000 }
|
||||
);
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the selected content type when the category id is blank', () => {
|
||||
store.setSelectedContentType('itv');
|
||||
|
||||
store.addToFavorites({ id: '9', category_id: ' ' });
|
||||
|
||||
expect(playlistService.addPortalFavorite).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
expect.objectContaining({
|
||||
id: '9',
|
||||
category_id: 'itv',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the item id when no stream id is present', () => {
|
||||
store.addToFavorites({ id: '77', name: 'Channel' });
|
||||
|
||||
expect(playlistService.addPortalFavorite).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
expect.objectContaining({ id: '77' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when no portal playlist is active', () => {
|
||||
const onDone = jest.fn();
|
||||
store.setCurrentPlaylist(undefined);
|
||||
|
||||
store.addToFavorites({ id: '9' }, onDone);
|
||||
|
||||
expect(playlistService.addPortalFavorite).not.toHaveBeenCalled();
|
||||
expect(ngrxStore.dispatch).not.toHaveBeenCalled();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromFavorites', () => {
|
||||
it('removes the favorite and syncs the emptied playlist meta', () => {
|
||||
const onDone = jest.fn();
|
||||
|
||||
store.removeFromFavorites('42', onDone);
|
||||
|
||||
expect(
|
||||
playlistService.removeFromPortalFavorites
|
||||
).toHaveBeenCalledWith('portal-1', '42');
|
||||
expect(ngrxStore.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
favorites: [],
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'PORTALS.REMOVED_FROM_FAVORITES',
|
||||
undefined,
|
||||
{ duration: 1000 }
|
||||
);
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no portal playlist is active', () => {
|
||||
const onDone = jest.fn();
|
||||
store.setCurrentPlaylist(undefined);
|
||||
|
||||
store.removeFromFavorites('42', onDone);
|
||||
|
||||
expect(
|
||||
playlistService.removeFromPortalFavorites
|
||||
).not.toHaveBeenCalled();
|
||||
expect(ngrxStore.dispatch).not.toHaveBeenCalled();
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerContentType } from '../stalker-store.contracts';
|
||||
import { withStalkerRecent } from './with-stalker-recent.feature';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
...jest.requireActual('@iptvnator/portal/shared/util'),
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const PLAYLIST = {
|
||||
_id: 'portal-1',
|
||||
title: 'Demo Stalker',
|
||||
count: 0,
|
||||
autoRefresh: false,
|
||||
importDate: '2026-04-14T00:00:00.000Z',
|
||||
portalUrl: 'http://demo.example/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
} as PlaylistMeta;
|
||||
|
||||
const TestRecentStore = signalStore(
|
||||
withState<{
|
||||
currentPlaylist: PlaylistMeta | undefined;
|
||||
selectedContentType: StalkerContentType;
|
||||
}>({
|
||||
currentPlaylist: PLAYLIST,
|
||||
selectedContentType: 'vod',
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
|
||||
patchState(store, { currentPlaylist: playlist });
|
||||
},
|
||||
setSelectedContentType(type: StalkerContentType) {
|
||||
patchState(store, { selectedContentType: type });
|
||||
},
|
||||
})),
|
||||
withStalkerRecent()
|
||||
);
|
||||
|
||||
describe('withStalkerRecent', () => {
|
||||
let store: InstanceType<typeof TestRecentStore>;
|
||||
let playlistService: {
|
||||
addPortalRecentlyViewed: jest.Mock;
|
||||
removeFromPortalRecentlyViewed: jest.Mock;
|
||||
};
|
||||
let ngrxStore: { dispatch: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
playlistService = {
|
||||
addPortalRecentlyViewed: jest.fn(() =>
|
||||
of({ recentlyViewed: [{ id: '22', title: 'Movie Title' }] })
|
||||
),
|
||||
removeFromPortalRecentlyViewed: jest.fn(() =>
|
||||
of({ recentlyViewed: [] })
|
||||
),
|
||||
};
|
||||
ngrxStore = { dispatch: jest.fn() };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestRecentStore,
|
||||
{ provide: PlaylistsService, useValue: playlistService },
|
||||
{ provide: Store, useValue: ngrxStore },
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(TestRecentStore);
|
||||
});
|
||||
|
||||
describe('addToRecentlyViewed', () => {
|
||||
it('persists a normalized recent item and syncs playlist meta', () => {
|
||||
store.addToRecentlyViewed({
|
||||
id: ' 22 ',
|
||||
name: 'Movie Title',
|
||||
category_id: '',
|
||||
});
|
||||
|
||||
expect(
|
||||
playlistService.addPortalRecentlyViewed
|
||||
).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
expect.objectContaining({
|
||||
id: '22',
|
||||
title: 'Movie Title',
|
||||
category_id: 'vod',
|
||||
added_at: expect.any(Number),
|
||||
})
|
||||
);
|
||||
expect(ngrxStore.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
recentlyViewed: [{ id: '22', title: 'Movie Title' }],
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('forces the series category and falls back to stream_id and o_name', () => {
|
||||
store.setSelectedContentType('series');
|
||||
|
||||
store.addToRecentlyViewed({
|
||||
stream_id: 30000,
|
||||
o_name: 'Original Series Name',
|
||||
category_id: '99',
|
||||
});
|
||||
|
||||
expect(
|
||||
playlistService.addPortalRecentlyViewed
|
||||
).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
expect.objectContaining({
|
||||
id: '30000',
|
||||
title: 'Original Series Name',
|
||||
category_id: 'series',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a provided category id for non-series content', () => {
|
||||
store.setSelectedContentType('itv');
|
||||
|
||||
store.addToRecentlyViewed({
|
||||
id: '5',
|
||||
title: 'Channel Five',
|
||||
category_id: '4001',
|
||||
});
|
||||
|
||||
expect(
|
||||
playlistService.addPortalRecentlyViewed
|
||||
).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
expect.objectContaining({
|
||||
id: '5',
|
||||
title: 'Channel Five',
|
||||
category_id: '4001',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when no portal playlist is active', () => {
|
||||
store.setCurrentPlaylist(undefined);
|
||||
|
||||
store.addToRecentlyViewed({ id: '22', title: 'Movie Title' });
|
||||
|
||||
expect(
|
||||
playlistService.addPortalRecentlyViewed
|
||||
).not.toHaveBeenCalled();
|
||||
expect(ngrxStore.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFromRecentlyViewed', () => {
|
||||
it('removes the item, syncs playlist meta, and signals completion', () => {
|
||||
const onComplete = jest.fn();
|
||||
|
||||
store.removeFromRecentlyViewed('22', onComplete);
|
||||
|
||||
expect(
|
||||
playlistService.removeFromPortalRecentlyViewed
|
||||
).toHaveBeenCalledWith('portal-1', '22');
|
||||
expect(ngrxStore.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: 'portal-1',
|
||||
recentlyViewed: [],
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows removal errors without syncing meta or completing', () => {
|
||||
const onComplete = jest.fn();
|
||||
playlistService.removeFromPortalRecentlyViewed.mockReturnValue(
|
||||
throwError(() => new Error('portal unreachable'))
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
store.removeFromRecentlyViewed('22', onComplete)
|
||||
).not.toThrow();
|
||||
|
||||
expect(ngrxStore.dispatch).not.toHaveBeenCalled();
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no portal playlist is active', () => {
|
||||
const onComplete = jest.fn();
|
||||
store.setCurrentPlaylist(undefined);
|
||||
|
||||
store.removeFromRecentlyViewed('22', onComplete);
|
||||
|
||||
expect(
|
||||
playlistService.removeFromPortalRecentlyViewed
|
||||
).not.toHaveBeenCalled();
|
||||
expect(onComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user