fix(settings): close lock editors on relock and clear withheld details opened from All

The Xtream, Stalker and M3U category editors are gated by the PIN only when
they open; an idle relock left them on screen listing locked names with a
lock-rewriting Save. Each now closes itself when the session relocks.

ParentalLockEnforcementService also judges the selected Xtream/Stalker
item by its own category: a detail opened from All, recently added or
search has no selected category to vanish with, so it stayed open after a
relock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 00:25:01 +02:00
1 parent 45661e311e
commit bc1b0eef41
9 files changed
+438 -21

No files matched your search

@@ -0,0 +1,169 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockEnforcementService } from './parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
interface Applier {
applyXtream(): Promise<void>;
applyStalker(): Promise<void>;
}
describe('ParentalLockEnforcementService', () => {
const router = { url: '/', navigate: jest.fn() };
const lockedStalkerIds = new Set<string>();
const parentalLock = {
version: signal(0),
registerBusyProbe: jest.fn(),
isStalkerCategoryLocked: jest.fn(
(_playlistId: string, _type: string, id: unknown) =>
id !== null &&
id !== undefined &&
lockedStalkerIds.has(String(id))
),
isM3uGroupLocked: jest.fn(() => false),
};
const stalkerStore = {
currentPlaylist: signal<{ _id: string } | null>({ _id: 'stalker-1' }),
selectedContentType: signal<string>('vod'),
selectedCategoryId: signal<string | null>('*'),
selectedItem: signal<{ category_id?: string } | null>(null),
clearSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
const xtreamStore = {
playlistId: signal<string | null>('xtream-1'),
selectedCategoryId: signal<number | null>(null),
selectedItem: signal<{ category_id?: number } | null>(null),
reloadCategories: jest.fn(async () => undefined),
reloadCachedContent: jest.fn(async () => undefined),
getCategoriesBySelectedType: jest.fn(() => [{ id: 7 }, { id: 8 }]),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
};
let service: Applier;
beforeEach(() => {
jest.clearAllMocks();
lockedStalkerIds.clear();
router.url = '/';
stalkerStore.selectedCategoryId.set('*');
stalkerStore.selectedItem.set(null);
xtreamStore.selectedCategoryId.set(null);
xtreamStore.selectedItem.set(null);
TestBed.configureTestingModule({
providers: [
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: XtreamStore, useValue: xtreamStore },
{ provide: StalkerStore, useValue: stalkerStore },
{ provide: Router, useValue: router },
{
provide: Store,
useValue: {
selectSignal: () => signal(null),
dispatch: jest.fn(),
},
},
{
provide: PlaybackKeepAwakeService,
useValue: { hasPlayingVideo: () => false },
},
],
});
service = TestBed.inject(
ParentalLockEnforcementService
) as unknown as Applier;
});
describe('Stalker', () => {
it('clears a detail opened from All whose own genre is withheld', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '9' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
// "All" itself is not locked, so the category stays selected.
expect(stalkerStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'vod',
]);
});
it('leaves a detail from All alone when its genre is not locked', async () => {
router.url = '/workspace/stalker/stalker-1/vod/42';
lockedStalkerIds.add('9');
stalkerStore.selectedItem.set({ category_id: '3' });
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('steps off a locked selected category', async () => {
router.url = '/workspace/stalker/stalker-1/vod';
lockedStalkerIds.add('9');
stalkerStore.selectedCategoryId.set('9');
await service.applyStalker();
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith(null);
});
});
describe('Xtream', () => {
it('clears a selected item whose category is no longer readable', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
xtreamStore.selectedItem.set({ category_id: 99 });
await service.applyXtream();
expect(xtreamStore.reloadCategories).toHaveBeenCalled();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'vod',
]);
});
it('keeps a selected item whose category survived the lock', async () => {
router.url = '/workspace/xtreams/xtream-1/vod/42';
xtreamStore.selectedCategoryId.set(7);
xtreamStore.selectedItem.set({ category_id: 7 });
await service.applyXtream();
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('steps off a selected category that vanished', async () => {
router.url = '/workspace/xtreams/xtream-1/live';
xtreamStore.selectedCategoryId.set(99);
await service.applyXtream();
expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null);
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'xtream-1',
'live',
]);
});
});
});
@@ -72,20 +72,42 @@ export class ParentalLockEnforcementService {
await this.xtreamStore.reloadCachedContent();
const match = XTREAM_ROUTE.exec(this.router.url);
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
if (selectedCategoryId === null) {
return;
}
const categoryType = toParentalLockXtreamCategoryType(match?.[2]);
const categories = this.xtreamStore.getCategoriesBySelectedType();
const stillVisible = categories.some(
(category) =>
Number(
(category as { id?: number | string }).id ??
(category as { category_id?: string }).category_id
) === Number(selectedCategoryId)
);
if (stillVisible) {
const isVisibleCategory = (categoryId: unknown): boolean =>
categories.some(
(category) =>
Number(
(category as { id?: number | string }).id ??
(category as { category_id?: string }).category_id
) === Number(categoryId)
);
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
// The selected ITEM is judged on its own category: opened from
// "All", recently added or search it has no selected category to
// vanish with, yet its detail must not outlive the lock.
const selectedItem = this.xtreamStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const itemWithheld =
selectedItem?.category_id !== undefined &&
selectedItem?.category_id !== null &&
!isVisibleCategory(selectedItem.category_id);
if (itemWithheld) {
this.xtreamStore.setSelectedItem(null);
}
if (
selectedCategoryId === null ||
isVisibleCategory(selectedCategoryId)
) {
if (itemWithheld && match && match[1] === playlistId) {
void this.router.navigate([
'/workspace',
'xtreams',
match[1],
match[2],
]);
}
return;
}
this.xtreamStore.setSelectedItem(null);
@@ -109,20 +131,36 @@ export class ParentalLockEnforcementService {
const contentType = toParentalLockStalkerCategoryType(
this.stalkerStore.selectedContentType()
);
if (!contentType) {
return;
}
const selectedCategoryId = this.stalkerStore.selectedCategoryId();
if (
!contentType ||
!selectedCategoryId ||
!this.parentalLock.isStalkerCategoryLocked(
const categoryWithheld =
!!selectedCategoryId &&
this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedCategoryId
)
) {
);
// An item opened from "All" (`*`) or search has its own genre to be
// judged by; the list dropping its row is not enough.
const selectedItem = this.stalkerStore.selectedItem?.() as {
category_id?: string | number;
} | null;
const itemWithheld =
!!selectedItem &&
this.parentalLock.isStalkerCategoryLocked(
playlistId,
contentType,
selectedItem.category_id
);
if (!categoryWithheld && !itemWithheld) {
return;
}
this.stalkerStore.clearSelectedItem();
this.stalkerStore.setSelectedCategory(null);
if (categoryWithheld) {
this.stalkerStore.setSelectedCategory(null);
}
const match = STALKER_ROUTE.exec(this.router.url);
if (match && match[1] === playlistId) {
void this.router.navigate([
+17 -2
View File
@@ -105,7 +105,18 @@ locked default.
`ParentalLockEnforcementService` (`apps/web/src/app/services/`) calls
`XtreamStore.reloadCategories()` + `reloadCachedContent()` and, if the
selected category vanished, clears the selection and navigates to the
section root.
section root. The selected ITEM is judged separately, on its own
`category_id` against the reloaded category list: a detail opened from
"All", recently added or search has no selected category to vanish
with, so its row disappearing from a list is not enough — the item is
cleared and the route returns to the section root while the (still
readable) selected category stays.
- **Stalker:** the same service checks the selected genre through
`isStalkerCategoryLocked` and, independently, the selected item's own
`category_id` — an item opened from `*` (All) or search is withheld by
its genre even though `*` itself can never be locked. A withheld item is
cleared and the section root is navigated to; the category selection is
reset only when the genre itself is locked.
- **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories,
streams and search hits at read time; the same reloads apply.
- **Warm-cache detection (Electron):** the filtered category/content reads
@@ -167,7 +178,11 @@ locked default.
`StalkerCategoryLockDialogComponent` reached from a lock button above the
categories rail; it offers "Lock adult (18+)" for genres the portal flags
`censored`. All three list the locked names and can rewrite the locks, so
each opens only after `requestUnlock()` succeeds. The Xtream dialog loads
each opens only after `requestUnlock()` succeeds — and closes itself,
discarding the draft, the moment `active` becomes true again (idle relock,
Lock now), since the PIN gate covers only the opening. The M3U group
dialog does this only while it carries lock toggles; the plain hide/show
editor is not behind the PIN. The Xtream dialog loads
its candidates through the capability-selected data source
(`IXtreamDataSource.getAllCategories`, which the PWA source answers from
its session cache or the API), so PWA users can set locks too; the
@@ -0,0 +1,68 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import {
StalkerCategoryLockDialogComponent,
StalkerCategoryLockDialogData,
} from './stalker-category-lock-dialog.component';
describe('StalkerCategoryLockDialogComponent', () => {
let fixture: ComponentFixture<StalkerCategoryLockDialogComponent>;
let component: StalkerCategoryLockDialogComponent;
const dialogRef = { close: jest.fn() };
const parentalLock = {
active: signal(false),
lockedStalkerIds: jest.fn(() => ['2']),
setStalkerLocks: jest.fn(),
};
const data: StalkerCategoryLockDialogData = {
playlistId: 'stalker-1',
contentType: 'itv',
categories: [
{ category_id: '1', category_name: 'News', censored: false },
{ category_id: '2', category_name: 'Adult', censored: true },
] as StalkerCategoryLockDialogData['categories'],
};
beforeEach(async () => {
jest.clearAllMocks();
parentalLock.active.set(false);
parentalLock.setStalkerLocks.mockResolvedValue(true);
await TestBed.configureTestingModule({
imports: [
StalkerCategoryLockDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
}).compileComponents();
fixture = TestBed.createComponent(StalkerCategoryLockDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('seeds the draft from the stored locks', () => {
expect(component.categories().map((c) => c.locked)).toEqual([
false,
true,
]);
expect(component.lockedCount()).toBe(1);
});
it('closes without saving when the session relocks while it is open', () => {
expect(dialogRef.close).not.toHaveBeenCalled();
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
@@ -82,6 +83,16 @@ export class StalkerCategoryLockDialogComponent {
() => this.categories().filter((category) => category.censored).length
);
constructor() {
// Same relock rule as the Xtream editor: the dialog outliving the
// unlocked session would keep the locked names and Save reachable.
effect(() => {
if (this.parentalLock.active()) {
this.dialogRef.close(false);
}
});
}
private buildDraft(): LockableCategory[] {
const locked = new Set(
this.parentalLock.lockedStalkerIds(
@@ -1,9 +1,11 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
ParentalLockService,
RuntimeCapabilitiesService,
XCategoryFromDb,
} from '@iptvnator/services';
@@ -35,6 +37,12 @@ describe('CategoryManagementDialogComponent', () => {
getAllCategories: jest.fn(),
};
const dialogRef = { close: jest.fn() };
const parentalLock = {
enabled: signal(false),
active: signal(false),
lockedXtreamIds: jest.fn(() => [] as number[]),
setXtreamLocks: jest.fn(),
};
const data: CategoryManagementDialogData = {
playlistId: 'mock-playlist',
contentType: 'live',
@@ -45,6 +53,8 @@ describe('CategoryManagementDialogComponent', () => {
jest.clearAllMocks();
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
data.contentType = 'live';
await TestBed.configureTestingModule({
imports: [
@@ -60,6 +70,7 @@ describe('CategoryManagementDialogComponent', () => {
},
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
],
}).compileComponents();
fixture = TestBed.createComponent(CategoryManagementDialogComponent);
@@ -184,6 +195,19 @@ describe('CategoryManagementDialogComponent', () => {
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('closes without saving when the session relocks while it is open', () => {
parentalLock.enabled.set(true);
fixture.detectChanges();
expect(dialogRef.close).not.toHaveBeenCalled();
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(db.updateCategoryVisibility).not.toHaveBeenCalled();
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
});
it('discards pending bulk changes on cancel', () => {
component.searchTerm.set('FR');
component.selectAll();
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
OnInit,
signal,
@@ -107,6 +108,17 @@ export class CategoryManagementDialogComponent implements OnInit {
this.filteredSelectedCount() === this.filteredCategories().length
);
constructor() {
// The PIN gate only covers opening: a relock (idle timer, Lock now)
// while the editor is open must take its locked names and its
// lock-rewriting Save away too.
effect(() => {
if (this.parentalLock.active()) {
this.dialogRef.close(false);
}
});
}
async ngOnInit(): Promise<void> {
await this.loadCategories();
}
@@ -0,0 +1,67 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import {
GroupManagementDialogComponent,
GroupManagementDialogData,
} from './group-management-dialog.component';
describe('GroupManagementDialogComponent', () => {
const dialogRef = { close: jest.fn() };
const parentalLock = { active: signal(false) };
async function create(
data: GroupManagementDialogData
): Promise<ComponentFixture<GroupManagementDialogComponent>> {
await TestBed.configureTestingModule({
imports: [
GroupManagementDialogComponent,
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
],
}).compileComponents();
const fixture = TestBed.createComponent(GroupManagementDialogComponent);
fixture.detectChanges();
return fixture;
}
const groups = [
{ key: 'News', count: 3 },
{ key: 'Adult', count: 2 },
];
beforeEach(() => {
jest.clearAllMocks();
parentalLock.active.set(false);
});
it('closes without a result when the session relocks while locks are shown', async () => {
const fixture = await create({
groups,
hiddenGroupTitles: [],
lockedGroupTitles: ['Adult'],
});
expect(dialogRef.close).not.toHaveBeenCalled();
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).toHaveBeenCalledWith(undefined);
});
it('keeps the plain hide/show editor open when the lock feature is off', async () => {
const fixture = await create({ groups, hiddenGroupTitles: ['News'] });
parentalLock.active.set(true);
fixture.detectChanges();
expect(dialogRef.close).not.toHaveBeenCalled();
expect(fixture.componentInstance.showLocks).toBe(false);
});
});
@@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
@@ -16,6 +17,7 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import { foldSearchText } from '@iptvnator/shared/interfaces';
export interface GroupManagementDialogGroup {
@@ -64,6 +66,7 @@ export class GroupManagementDialogComponent {
>
);
readonly data = inject<GroupManagementDialogData>(MAT_DIALOG_DATA);
private readonly parentalLock = inject(ParentalLockService);
readonly showLocks = this.data.lockedGroupTitles !== undefined;
readonly searchTerm = signal('');
@@ -100,6 +103,16 @@ export class GroupManagementDialogComponent {
this.groups().every((group) => group.selected)
);
constructor() {
// A relock while the editor is open: the locked group names it
// lists and the lock list it can rewrite are behind the PIN.
effect(() => {
if (this.showLocks && this.parentalLock.active()) {
this.dialogRef.close(undefined);
}
});
}
clearSearch(): void {
this.searchTerm.set('');
}