fix(stalker): serialize overlapping edits

This commit is contained in:
4gray committed 2026-08-09 10:27:33 +02:00
1 parent a7ccdc905b
commit 7c8b94a9ed
7 files changed
+43 -7

No files matched your search

@@ -51,6 +51,7 @@ describe('PlaylistInfoComponent', () => {
};
let dialogRef: {
close: jest.Mock;
disableClose: boolean;
};
let stalkerConnectionEditor: {
resolveConnection: jest.Mock;
@@ -104,6 +105,7 @@ describe('PlaylistInfoComponent', () => {
};
dialogRef = {
close: jest.fn(),
disableClose: false,
};
stalkerConnectionEditor = {
applyResolvedConnection: jest.fn().mockResolvedValue(undefined),
@@ -852,6 +854,7 @@ describe('PlaylistInfoComponent', () => {
const secondSave = component.saveChanges(value);
expect(component.isSaving()).toBe(true);
expect(dialogRef.disableClose).toBe(true);
expect(
stalkerConnectionEditor.resolveConnection
).toHaveBeenCalledTimes(1);
@@ -861,6 +864,7 @@ describe('PlaylistInfoComponent', () => {
});
await Promise.all([firstSave, secondSave]);
expect(store.dispatch).not.toHaveBeenCalled();
expect(dialogRef.disableClose).toBe(false);
});
it('accepts a bare HTTP host but rejects an address without a protocol', () => {
@@ -872,6 +876,9 @@ describe('PlaylistInfoComponent', () => {
control?.setValue('https://portal.example.com');
expect(control?.valid).toBe(true);
control?.setValue('HTTP://portal.example.com/c');
expect(control?.valid).toBe(true);
});
it('persists a grandfathered MAC untouched on submit', async () => {
@@ -365,6 +365,9 @@ export class PlaylistInfoComponent {
}
this.isSaving.set(true);
if (this.dialogRef) {
this.dialogRef.disableClose = true;
}
try {
let resolvedStalkerConnection = false;
let normalizedPlaylist: PlaylistMetaUpdate =
@@ -446,6 +449,9 @@ export class PlaylistInfoComponent {
}
);
} finally {
if (this.dialogRef) {
this.dialogRef.disableClose = false;
}
this.isSaving.set(false);
}
}
@@ -5,7 +5,7 @@ import {
type PlaylistMeta,
} from '@iptvnator/shared/interfaces';
export const STALKER_PORTAL_URL_PATTERN = /^\s*https?:\/\/[^ "\s]+\s*$/;
export const STALKER_PORTAL_URL_PATTERN = /^\s*https?:\/\/[^ "\s]+\s*$/i;
const STALKER_CONNECTION_FIELDS = [
'portalUrl',
@@ -129,6 +129,24 @@ describe('Stalker edited-session coordination', () => {
service.cancelEditDiscovery(fence);
});
it('rejects an overlapping edit without retiring the first owner', async () => {
const firstFence = await service.beginEditDiscovery(oldPlaylist);
await expect(
service.beginEditDiscovery({
...oldPlaylist,
username: 'second-edit',
})
).rejects.toThrow(/already in progress/i);
await expect(
service.replaceSessionAfterEdit(
{ ...oldPlaylist, stalkerToken: 'FIRST_EDIT_TOKEN' },
firstFence
)
).resolves.toEqual(oldPlaylist);
});
it('prevents late pre-edit auth from restoring a cleared simple session', async () => {
const oldAuthentication = service.ensureToken(oldPlaylist);
while (authenticate.mock.calls.length === 0) {
@@ -69,6 +69,9 @@ export class StalkerEditedSessionCoordinator {
async beginEdit(playlist: Playlist): Promise<StalkerEditFence> {
const playlistId = playlist._id;
if (this.pendingEdits.has(playlistId)) {
throw new Error('Stalker playlist edit already in progress');
}
const fingerprint = stalkerSessionFingerprint(playlist);
const fence = { playlistId, owner: Symbol('stalker-edit') };
this.pendingEdits.set(playlistId, {