mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(settings): build bulk lock drafts only from a readable lock store
The Xtream and M3U management dialogs offer lock toggles, and the Stalker lock dialog opens, only once the lock store has been read. A draft built from the empty fail-closed snapshot would otherwise replace the real locks with nothing on Save if storage recovered in between. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ceb0653415
commit
ed48e7fd2b
7 files changed
+94
-5
No files matched your search
@@ -375,7 +375,12 @@ on either side.
|
||||
playlists can share a group name). The M3U management dialog is bound
|
||||
the same way: `GroupsViewComponent` captures its `playlistId` input
|
||||
before the PIN and drops the dialog's result once another playlist is
|
||||
open. Bulk actions stay in the dialog, and while the session is locked
|
||||
open. Every bulk lock editor (Xtream and M3U management dialogs, the
|
||||
Stalker lock dialog) builds its draft only from a lock store that was
|
||||
read (`ensureLocksReadable`): a draft taken from the empty fail-closed
|
||||
snapshot would erase the real locks on Save if storage recovered in
|
||||
between. The M3U and Xtream dialogs then open without lock toggles; the
|
||||
Stalker dialog does not open. Bulk actions stay in the dialog, and while the session is locked
|
||||
a locked category is not in the rail, so unlocking always goes through
|
||||
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
|
||||
hands its lock list back to `ChannelListContainerComponent`, which
|
||||
|
||||
+14
@@ -42,6 +42,7 @@ describe('CategoryManagementDialogComponent', () => {
|
||||
active: signal(false),
|
||||
lockedXtreamIds: jest.fn(() => [] as number[]),
|
||||
setXtreamLocks: jest.fn(),
|
||||
ensureLocksReadable: jest.fn(async () => true),
|
||||
};
|
||||
const data: CategoryManagementDialogData = {
|
||||
playlistId: 'mock-playlist',
|
||||
@@ -210,6 +211,7 @@ describe('CategoryManagementDialogComponent', () => {
|
||||
|
||||
it('drops the lock draft when the session relocks during a pending save', async () => {
|
||||
parentalLock.enabled.set(true);
|
||||
component.showLocks.set(true);
|
||||
fixture.detectChanges();
|
||||
let finishVisibility: () => void = () => undefined;
|
||||
db.updateCategoryVisibility.mockImplementationOnce(
|
||||
@@ -225,6 +227,18 @@ describe('CategoryManagementDialogComponent', () => {
|
||||
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers no lock toggles, and saves no locks, while the lock store is unreadable', async () => {
|
||||
parentalLock.enabled.set(true);
|
||||
parentalLock.ensureLocksReadable.mockResolvedValueOnce(false);
|
||||
await component.ngOnInit();
|
||||
expect(component.showLocks()).toBe(false);
|
||||
|
||||
await component.save();
|
||||
|
||||
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
|
||||
expect(dialogRef.close).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('discards pending bulk changes on cancel', () => {
|
||||
component.searchTerm.set('FR');
|
||||
component.selectAll();
|
||||
|
||||
+10
-1
@@ -74,7 +74,12 @@ export class CategoryManagementDialogComponent implements OnInit {
|
||||
private readonly logger = createLogger('CategoryManagementDialog');
|
||||
|
||||
/** Lock toggles exist only while the parental lock feature is on. */
|
||||
readonly showLocks = this.parentalLock.enabled;
|
||||
/**
|
||||
* Lock toggles, only once the lock store has been read: a draft built
|
||||
* from the empty fail-closed snapshot would, if storage recovered by
|
||||
* Save, replace the real locks with nothing.
|
||||
*/
|
||||
readonly showLocks = signal(false);
|
||||
readonly lockedCount = computed(
|
||||
() => this.categories().filter((c) => c.lockedDraft).length
|
||||
);
|
||||
@@ -126,6 +131,10 @@ export class CategoryManagementDialogComponent implements OnInit {
|
||||
private async loadCategories(): Promise<void> {
|
||||
try {
|
||||
const type = this.getDbType();
|
||||
this.showLocks.set(
|
||||
this.parentalLock.enabled() &&
|
||||
(await this.parentalLock.ensureLocksReadable())
|
||||
);
|
||||
const allCategories = await this.waitForCategories(type);
|
||||
// The renderer's lock store is authoritative; the row's `locked`
|
||||
// column is only its SQLite mirror.
|
||||
|
||||
+23
-2
@@ -47,7 +47,11 @@ describe('GroupsViewComponent', () => {
|
||||
let fixture: ComponentFixture<GroupsViewComponent>;
|
||||
let component: GroupsViewComponent;
|
||||
let dialog: { open: jest.Mock };
|
||||
let parentalLock: { requestUnlock: jest.Mock };
|
||||
let parentalLock: {
|
||||
requestUnlock: jest.Mock;
|
||||
ensureLocksReadable: jest.Mock;
|
||||
lockedGroupTitles: jest.Mock;
|
||||
};
|
||||
|
||||
const sportsCenter = createChannel(
|
||||
'sports-1',
|
||||
@@ -104,7 +108,11 @@ describe('GroupsViewComponent', () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
|
||||
|
||||
parentalLock = { requestUnlock: jest.fn().mockResolvedValue(true) };
|
||||
parentalLock = {
|
||||
requestUnlock: jest.fn().mockResolvedValue(true),
|
||||
ensureLocksReadable: jest.fn().mockResolvedValue(true),
|
||||
lockedGroupTitles: jest.fn(() => ['News']),
|
||||
};
|
||||
|
||||
dialog = {
|
||||
open: jest.fn(),
|
||||
@@ -537,6 +545,19 @@ describe('GroupsViewComponent', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens the manage-groups dialog without lock toggles while the lock store is unreadable', async () => {
|
||||
fixture.componentRef.setInput('playlistId', 'playlist-a');
|
||||
fixture.componentRef.setInput('lockedGroupTitles', []);
|
||||
fixture.detectChanges();
|
||||
parentalLock.ensureLocksReadable.mockResolvedValue(false);
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
|
||||
|
||||
await component.openGroupManagement();
|
||||
|
||||
const data = dialog.open.mock.calls[0][1].data;
|
||||
expect(data.lockedGroupTitles).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the manage-groups result once another playlist is open', async () => {
|
||||
const hiddenGroupTitlesChanged = jest.fn();
|
||||
const lockedGroupTitlesChanged = jest.fn();
|
||||
|
||||
+11
-1
@@ -556,7 +556,17 @@ export class GroupsViewComponent {
|
||||
count,
|
||||
})
|
||||
);
|
||||
const lockedGroupTitles = this.lockedGroupTitles();
|
||||
// Lock toggles only from a lock store that was read: a draft built
|
||||
// from the empty fail-closed snapshot would, if storage recovered by
|
||||
// Save, replace the real locks with nothing. Hidden groups stay
|
||||
// editable either way.
|
||||
const lockedGroupTitles =
|
||||
this.lockedGroupTitles() !== null &&
|
||||
playlistId &&
|
||||
(await this.parentalLock.ensureLocksReadable()) &&
|
||||
this.playlistId() === playlistId
|
||||
? this.parentalLock.lockedGroupTitles(playlistId)
|
||||
: null;
|
||||
const dialogRef = this.dialog.open(GroupManagementDialogComponent, {
|
||||
data: {
|
||||
groups,
|
||||
|
||||
+25
@@ -967,6 +967,9 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
value: signal(true),
|
||||
});
|
||||
jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true);
|
||||
jest.spyOn(parentalLock, 'ensureLocksReadable').mockResolvedValue(
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
it('opens with the categories snapshotted before the lazy import', async () => {
|
||||
@@ -1027,6 +1030,28 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens nothing while the lock store cannot be read', async () => {
|
||||
enableLock();
|
||||
jest.spyOn(
|
||||
TestBed.inject(ParentalLockService),
|
||||
'ensureLocksReadable'
|
||||
).mockResolvedValue(false);
|
||||
fixture.componentRef.setInput('context', {
|
||||
provider: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
});
|
||||
fixture.componentRef.setInput('section', 'itv');
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
component.loadStalkerLockDialog = jest.fn(
|
||||
async () => class DialogStub {}
|
||||
);
|
||||
|
||||
await component.openManageStalkerCategories();
|
||||
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens nothing when the route changed during the lazy import', async () => {
|
||||
enableLock();
|
||||
fixture.componentRef.setInput('context', {
|
||||
|
||||
+5
@@ -548,6 +548,11 @@ export class WorkspaceContextPanelComponent {
|
||||
if (!contentType || !(await this.parentalLock.requestUnlock())) {
|
||||
return;
|
||||
}
|
||||
// The dialog's draft is the lock store's list: built from the empty
|
||||
// fail-closed snapshot it would erase the real locks on Save.
|
||||
if (!unchanged() || !(await this.parentalLock.ensureLocksReadable())) {
|
||||
return;
|
||||
}
|
||||
if (!unchanged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user