diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts new file mode 100644 index 000000000..60ccf20b0 --- /dev/null +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -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; + applyStalker(): Promise; +} + +describe('ParentalLockEnforcementService', () => { + const router = { url: '/', navigate: jest.fn() }; + const lockedStalkerIds = new Set(); + 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('vod'), + selectedCategoryId: signal('*'), + selectedItem: signal<{ category_id?: string } | null>(null), + clearSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), + }; + const xtreamStore = { + playlistId: signal('xtream-1'), + selectedCategoryId: signal(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', + ]); + }); + }); +}); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 117e21cb7..2aa7d2059 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -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([ diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 50ef7c458..66de43542 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts new file mode 100644 index 000000000..0e410f21b --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.ts b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.ts index cfe15fe3b..d4a838d98 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.ts @@ -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( diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts index f56c8644d..bc4969e42 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts @@ -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(); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index b3ffa1594..af434db89 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -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 { await this.loadCategories(); } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.spec.ts new file mode 100644 index 000000000..03306b7f8 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.spec.ts @@ -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> { + 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); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts index 865097935..710ab30ca 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts @@ -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(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(''); }