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
|
playlists can share a group name). The M3U management dialog is bound
|
||||||
the same way: `GroupsViewComponent` captures its `playlistId` input
|
the same way: `GroupsViewComponent` captures its `playlistId` input
|
||||||
before the PIN and drops the dialog's result once another playlist is
|
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
|
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
|
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
|
||||||
hands its lock list back to `ChannelListContainerComponent`, which
|
hands its lock list back to `ChannelListContainerComponent`, which
|
||||||
|
|||||||
+14
@@ -42,6 +42,7 @@ describe('CategoryManagementDialogComponent', () => {
|
|||||||
active: signal(false),
|
active: signal(false),
|
||||||
lockedXtreamIds: jest.fn(() => [] as number[]),
|
lockedXtreamIds: jest.fn(() => [] as number[]),
|
||||||
setXtreamLocks: jest.fn(),
|
setXtreamLocks: jest.fn(),
|
||||||
|
ensureLocksReadable: jest.fn(async () => true),
|
||||||
};
|
};
|
||||||
const data: CategoryManagementDialogData = {
|
const data: CategoryManagementDialogData = {
|
||||||
playlistId: 'mock-playlist',
|
playlistId: 'mock-playlist',
|
||||||
@@ -210,6 +211,7 @@ describe('CategoryManagementDialogComponent', () => {
|
|||||||
|
|
||||||
it('drops the lock draft when the session relocks during a pending save', async () => {
|
it('drops the lock draft when the session relocks during a pending save', async () => {
|
||||||
parentalLock.enabled.set(true);
|
parentalLock.enabled.set(true);
|
||||||
|
component.showLocks.set(true);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
let finishVisibility: () => void = () => undefined;
|
let finishVisibility: () => void = () => undefined;
|
||||||
db.updateCategoryVisibility.mockImplementationOnce(
|
db.updateCategoryVisibility.mockImplementationOnce(
|
||||||
@@ -225,6 +227,18 @@ describe('CategoryManagementDialogComponent', () => {
|
|||||||
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
|
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', () => {
|
it('discards pending bulk changes on cancel', () => {
|
||||||
component.searchTerm.set('FR');
|
component.searchTerm.set('FR');
|
||||||
component.selectAll();
|
component.selectAll();
|
||||||
|
|||||||
+10
-1
@@ -74,7 +74,12 @@ export class CategoryManagementDialogComponent implements OnInit {
|
|||||||
private readonly logger = createLogger('CategoryManagementDialog');
|
private readonly logger = createLogger('CategoryManagementDialog');
|
||||||
|
|
||||||
/** Lock toggles exist only while the parental lock feature is on. */
|
/** 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(
|
readonly lockedCount = computed(
|
||||||
() => this.categories().filter((c) => c.lockedDraft).length
|
() => this.categories().filter((c) => c.lockedDraft).length
|
||||||
);
|
);
|
||||||
@@ -126,6 +131,10 @@ export class CategoryManagementDialogComponent implements OnInit {
|
|||||||
private async loadCategories(): Promise<void> {
|
private async loadCategories(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const type = this.getDbType();
|
const type = this.getDbType();
|
||||||
|
this.showLocks.set(
|
||||||
|
this.parentalLock.enabled() &&
|
||||||
|
(await this.parentalLock.ensureLocksReadable())
|
||||||
|
);
|
||||||
const allCategories = await this.waitForCategories(type);
|
const allCategories = await this.waitForCategories(type);
|
||||||
// The renderer's lock store is authoritative; the row's `locked`
|
// The renderer's lock store is authoritative; the row's `locked`
|
||||||
// column is only its SQLite mirror.
|
// column is only its SQLite mirror.
|
||||||
|
|||||||
+23
-2
@@ -47,7 +47,11 @@ describe('GroupsViewComponent', () => {
|
|||||||
let fixture: ComponentFixture<GroupsViewComponent>;
|
let fixture: ComponentFixture<GroupsViewComponent>;
|
||||||
let component: GroupsViewComponent;
|
let component: GroupsViewComponent;
|
||||||
let dialog: { open: jest.Mock };
|
let dialog: { open: jest.Mock };
|
||||||
let parentalLock: { requestUnlock: jest.Mock };
|
let parentalLock: {
|
||||||
|
requestUnlock: jest.Mock;
|
||||||
|
ensureLocksReadable: jest.Mock;
|
||||||
|
lockedGroupTitles: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
const sportsCenter = createChannel(
|
const sportsCenter = createChannel(
|
||||||
'sports-1',
|
'sports-1',
|
||||||
@@ -104,7 +108,11 @@ describe('GroupsViewComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
|
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 = {
|
dialog = {
|
||||||
open: jest.fn(),
|
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 () => {
|
it('drops the manage-groups result once another playlist is open', async () => {
|
||||||
const hiddenGroupTitlesChanged = jest.fn();
|
const hiddenGroupTitlesChanged = jest.fn();
|
||||||
const lockedGroupTitlesChanged = jest.fn();
|
const lockedGroupTitlesChanged = jest.fn();
|
||||||
|
|||||||
+11
-1
@@ -556,7 +556,17 @@ export class GroupsViewComponent {
|
|||||||
count,
|
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, {
|
const dialogRef = this.dialog.open(GroupManagementDialogComponent, {
|
||||||
data: {
|
data: {
|
||||||
groups,
|
groups,
|
||||||
|
|||||||
+25
@@ -967,6 +967,9 @@ describe('WorkspaceContextPanelComponent', () => {
|
|||||||
value: signal(true),
|
value: signal(true),
|
||||||
});
|
});
|
||||||
jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true);
|
jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true);
|
||||||
|
jest.spyOn(parentalLock, 'ensureLocksReadable').mockResolvedValue(
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('opens with the categories snapshotted before the lazy import', async () => {
|
it('opens with the categories snapshotted before the lazy import', async () => {
|
||||||
@@ -1027,6 +1030,28 @@ describe('WorkspaceContextPanelComponent', () => {
|
|||||||
expect(dialog.open).not.toHaveBeenCalled();
|
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 () => {
|
it('opens nothing when the route changed during the lazy import', async () => {
|
||||||
enableLock();
|
enableLock();
|
||||||
fixture.componentRef.setInput('context', {
|
fixture.componentRef.setInput('context', {
|
||||||
|
|||||||
+5
@@ -548,6 +548,11 @@ export class WorkspaceContextPanelComponent {
|
|||||||
if (!contentType || !(await this.parentalLock.requestUnlock())) {
|
if (!contentType || !(await this.parentalLock.requestUnlock())) {
|
||||||
return;
|
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()) {
|
if (!unchanged()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user