refactor(web): harden runtime service payloads

This commit is contained in:
4gray committed 2026-05-22 11:20:50 +03:00
1 parent 76346a569b
commit 1806cb46aa
4 files changed
+205 -8

No files matched your search

@@ -0,0 +1,117 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PLAYLIST_PARSE_BY_URL } from '@iptvnator/shared/interfaces';
import { ElectronService } from './electron.service';
describe('ElectronService', () => {
const session = { id: 'session-1' };
let electronBridge: {
fetchPlaylistByUrl: jest.Mock;
openInMpv: jest.Mock;
openInVlc: jest.Mock;
};
let service: ElectronService;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
electronBridge = {
fetchPlaylistByUrl: jest.fn(),
openInMpv: jest.fn().mockResolvedValue(session),
openInVlc: jest.fn().mockResolvedValue(session),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: electronBridge,
});
TestBed.configureTestingModule({
providers: [
ElectronService,
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: Store,
useValue: {
dispatch: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
service = TestBed.inject(ElectronService);
});
afterEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: undefined,
});
jest.restoreAllMocks();
});
it('ignores URL imports without a payload instead of calling the Electron bridge', async () => {
await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL);
expect(electronBridge.fetchPlaylistByUrl).not.toHaveBeenCalled();
});
it('preserves an absent MPV user-agent so backend fallback headers can apply', async () => {
await service.sendIpcEvent('OPEN_MPV_PLAYER', {
url: 'https://example.test/live.m3u8',
headers: {
'User-Agent': 'FallbackAgent/1.0',
},
});
expect(electronBridge.openInMpv).toHaveBeenCalledWith(
'https://example.test/live.m3u8',
'',
'',
undefined,
undefined,
undefined,
undefined,
undefined,
{
'User-Agent': 'FallbackAgent/1.0',
}
);
});
it('preserves an absent VLC user-agent so backend fallback headers can apply', async () => {
await service.sendIpcEvent('OPEN_VLC_PLAYER', {
url: 'https://example.test/live.m3u8',
headers: {
'User-Agent': 'FallbackAgent/1.0',
},
});
expect(electronBridge.openInVlc).toHaveBeenCalledWith(
'https://example.test/live.m3u8',
'',
'',
undefined,
undefined,
undefined,
undefined,
undefined,
{
'User-Agent': 'FallbackAgent/1.0',
}
);
});
});
@@ -156,7 +156,7 @@ export class ElectronService extends DataService {
data.url,
data.title ?? '',
data.thumbnail ?? '',
data['user-agent'] ?? undefined,
data['user-agent'],
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
@@ -185,7 +185,7 @@ export class ElectronService extends DataService {
data.url,
data.title ?? '',
data.thumbnail ?? '',
data['user-agent'] ?? undefined,
data['user-agent'],
data.referer ?? undefined,
data.origin ?? undefined,
data.contentInfo,
@@ -265,8 +265,8 @@ export class ElectronService extends DataService {
}
}
private async fetchM3uPlaylistFromUrl(payload: Partial<Playlist>) {
if (!payload.url) {
private async fetchM3uPlaylistFromUrl(payload?: Partial<Playlist>) {
if (!payload?.url) {
return;
}
@@ -0,0 +1,80 @@
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { SwUpdate } from '@angular/service-worker';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { EMPTY } from 'rxjs';
import {
PLAYLIST_PARSE_BY_URL,
PLAYLIST_UPDATE,
} from '@iptvnator/shared/interfaces';
import { PwaService } from './pwa.service';
describe('PwaService', () => {
let http: HttpTestingController;
let service: PwaService;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
PwaService,
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: Store,
useValue: {
dispatch: jest.fn(),
},
},
{
provide: SwUpdate,
useValue: {
versionUpdates: EMPTY,
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
http = TestBed.inject(HttpTestingController);
service = TestBed.inject(PwaService);
});
afterEach(() => {
http.verify();
jest.restoreAllMocks();
});
it('ignores URL imports without a payload or URL instead of calling the backend', () => {
service.sendIpcEvent(PLAYLIST_PARSE_BY_URL);
service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {});
expect(http.match(() => true)).toHaveLength(0);
});
it('ignores playlist refreshes without a payload, URL, or id instead of calling the backend', () => {
service.sendIpcEvent(PLAYLIST_UPDATE);
service.sendIpcEvent(PLAYLIST_UPDATE, { id: 'playlist-1' });
service.sendIpcEvent(PLAYLIST_UPDATE, {
url: 'https://example.test/playlist.m3u',
});
expect(http.match(() => true)).toHaveLength(0);
});
});
+4 -4
View File
@@ -144,8 +144,8 @@ export class PwaService extends DataService {
return undefined as T;
}
refreshPlaylist(payload: Partial<Playlist & { id: string }>) {
if (!payload.url || !payload.id) {
refreshPlaylist(payload?: Partial<Playlist & { id: string }>) {
if (!payload?.url || !payload?.id) {
return;
}
@@ -205,8 +205,8 @@ export class PwaService extends DataService {
* Fetches playlist from the specified url
* @param payload playlist payload
*/
fetchFromUrl(payload: Partial<Playlist>): void {
if (!payload.url) {
fetchFromUrl(payload?: Partial<Playlist>): void {
if (!payload?.url) {
return;
}