mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(stalker): harden session compatibility
This commit is contained in:
1 parent
63ab2837a7
commit
bdf353a3b8
31 files changed
+1067
-82
No files matched your search
+188
-6
@@ -15,7 +15,7 @@ import {
|
||||
StalkerSessionConnectionOutcome,
|
||||
StalkerSessionFullReadyOutcome,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EMPTY, Subject, of, throwError } from 'rxjs';
|
||||
import { Subject, of, throwError } from 'rxjs';
|
||||
import { StalkerCredentialsDialogComponent } from './stalker-credentials-dialog.component';
|
||||
import { StalkerConnectionFlowService } from './stalker-connection-flow.service';
|
||||
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
|
||||
@@ -178,6 +178,14 @@ describe('StalkerConnectionFlowService', () => {
|
||||
let dialog: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let snackAction: Subject<void>;
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let snackRef: {
|
||||
dismiss: jest.Mock;
|
||||
onAction: () => Subject<void>;
|
||||
};
|
||||
let queuedDialogResults: unknown[];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -190,6 +198,14 @@ describe('StalkerConnectionFlowService', () => {
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
snackAction = new Subject<void>();
|
||||
snackRef = {
|
||||
dismiss: jest.fn(),
|
||||
onAction: () => snackAction,
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(() => snackRef),
|
||||
};
|
||||
queuedDialogResults = [];
|
||||
dialog = {
|
||||
open: jest.fn(() => ({
|
||||
@@ -219,11 +235,7 @@ describe('StalkerConnectionFlowService', () => {
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: {
|
||||
open: jest.fn(() => ({
|
||||
onAction: () => EMPTY,
|
||||
})),
|
||||
},
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
@@ -602,6 +614,138 @@ describe('StalkerConnectionFlowService', () => {
|
||||
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers an actionable retry with the stable terminal reason', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'incompatible-response',
|
||||
requestId: 'request-terminal-failure',
|
||||
retryable: false,
|
||||
stage: 'resolving',
|
||||
});
|
||||
session.forceRedetect.mockResolvedValueOnce(READY);
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining('incompatible-response'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
|
||||
snackAction.next();
|
||||
snackAction.next();
|
||||
await flushPromises();
|
||||
|
||||
expect(session.forceRedetect).toHaveBeenCalledWith('lease-route-1');
|
||||
expect(session.forceRedetect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidates a terminal retry action when navigation cancels the flow', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'request-timeout',
|
||||
requestId: 'request-terminal-cancel',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
});
|
||||
|
||||
await service.ensureConnected(LEGACY_PLAYLIST);
|
||||
await service.cancel();
|
||||
snackAction.next();
|
||||
await flushPromises();
|
||||
|
||||
expect(snackRef.dismiss).toHaveBeenCalled();
|
||||
expect(session.forceRedetect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates an in-flight typed run when a new run resolves through the stateless path', async () => {
|
||||
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
|
||||
session.open.mockReturnValueOnce(deferredOpen.promise);
|
||||
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
|
||||
await flushPromises();
|
||||
|
||||
const statelessPlaylist = {
|
||||
...LEGACY_PLAYLIST,
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'stateless-mac' as const,
|
||||
};
|
||||
await expect(
|
||||
service.ensureConnected(statelessPlaylist)
|
||||
).resolves.toBe(statelessPlaylist);
|
||||
|
||||
deferredOpen.resolve({
|
||||
kind: 'failure',
|
||||
reason: 'request-timeout',
|
||||
requestId: 'request-stale-typed-failure',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
});
|
||||
await expect(connecting).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns an asynchronous force-redetect continuation rejection into an actionable failure', async () => {
|
||||
session.forceRedetect.mockResolvedValueOnce({
|
||||
attemptNumber: 1,
|
||||
attemptRef: 'attempt-redetect-rejection',
|
||||
challengeRef: 'challenge-redetect-rejection',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-redetect-rejection',
|
||||
});
|
||||
session.continue.mockRejectedValueOnce(
|
||||
new Error('redetect-continue-failed')
|
||||
);
|
||||
queuedDialogResults.push({
|
||||
password: 'password',
|
||||
username: 'user',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.forceRedetect(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('connection-redetect-failed'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('turns an asynchronous recovery continuation rejection into an actionable failure', async () => {
|
||||
session.open.mockResolvedValueOnce({
|
||||
attemptNumber: 1,
|
||||
attemptRef: 'attempt-recovery-rejection',
|
||||
challengeRef: 'challenge-recovery-rejection',
|
||||
kind: 'credentials-required',
|
||||
requestId: 'request-recovery-rejection',
|
||||
});
|
||||
session.continue.mockRejectedValueOnce(
|
||||
new Error('recovery-continue-failed')
|
||||
);
|
||||
queuedDialogResults.push({
|
||||
password: 'password',
|
||||
username: 'user',
|
||||
});
|
||||
const handler = session.recoveryHandler();
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
await expect(
|
||||
handler?.({
|
||||
playlist: LEGACY_PLAYLIST,
|
||||
trigger: 'endpoint-shape',
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('connection-recovery-failed'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('retains a ready attempt after a local write failure and retries the same draft', async () => {
|
||||
session.open.mockResolvedValue(READY);
|
||||
playlists.persistStalkerConnection.mockReturnValueOnce(
|
||||
@@ -679,6 +823,44 @@ describe('StalkerConnectionFlowService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the actionable terminal reason when promotion recovery returns a failure', async () => {
|
||||
session.open.mockResolvedValueOnce(READY).mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'portal-unavailable',
|
||||
requestId: 'request-promotion-reopen-failure',
|
||||
retryable: true,
|
||||
stage: 'handshaking',
|
||||
});
|
||||
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('portal-unavailable'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('offers an actionable retry when promotion recovery cannot reopen the persisted playlist', async () => {
|
||||
session.open
|
||||
.mockResolvedValueOnce(READY)
|
||||
.mockRejectedValueOnce(new Error('reopen-failed'));
|
||||
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
|
||||
|
||||
await expect(
|
||||
service.ensureConnected(LEGACY_PLAYLIST)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(snackBar.open).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('session-promotion-failed'),
|
||||
'HOME.STALKER_PORTAL.RETRY',
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('discards a provisional ready outcome that arrives after cancellation of open', async () => {
|
||||
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
|
||||
session.open.mockReturnValueOnce(deferredOpen.promise);
|
||||
|
||||
+93
-28
@@ -47,6 +47,7 @@ export class StalkerConnectionFlowService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly readySubject = new Subject<Playlist>();
|
||||
private activeDialog: { close: () => void } | null = null;
|
||||
private activeConnectionRetry: { dismiss: () => void } | null = null;
|
||||
private activeAttemptRef: string | null = null;
|
||||
private pendingPersistence: PendingPersistence | null = null;
|
||||
private runId = 0;
|
||||
@@ -65,10 +66,11 @@ export class StalkerConnectionFlowService {
|
||||
}
|
||||
|
||||
async ensureConnected(playlist: Playlist): Promise<Playlist | undefined> {
|
||||
const runId = ++this.runId;
|
||||
this.dismissConnectionRetry();
|
||||
if (!this.shouldUseTypedSession(playlist)) {
|
||||
return playlist;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
await this.discardPending();
|
||||
const needsMigration = !this.hasVerifiedRecipe(playlist);
|
||||
try {
|
||||
@@ -83,33 +85,49 @@ export class StalkerConnectionFlowService {
|
||||
);
|
||||
return await this.handleOutcome(playlist, outcome, runId, false);
|
||||
} catch {
|
||||
this.offerConnectionRetry(
|
||||
playlist,
|
||||
'connection-open-failed',
|
||||
runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async forceRedetect(playlist: Playlist): Promise<Playlist | undefined> {
|
||||
if (!this.session.supportsTypedSessions()) {
|
||||
this.dismissConnectionRetry();
|
||||
return playlist;
|
||||
}
|
||||
const leaseRef = this.session.getLeaseRef(playlist._id);
|
||||
if (leaseRef === undefined) {
|
||||
const runId = ++this.runId;
|
||||
await this.discardPending();
|
||||
try {
|
||||
const runId = ++this.runId;
|
||||
this.dismissConnectionRetry();
|
||||
try {
|
||||
if (leaseRef === undefined) {
|
||||
await this.discardPending();
|
||||
const outcome = await this.session.open(playlist, {
|
||||
connectionMode: 'provisional',
|
||||
provisionalReason: 'migration',
|
||||
});
|
||||
return this.handleOutcome(playlist, outcome, runId, true);
|
||||
} catch {
|
||||
return undefined;
|
||||
return await this.handleOutcome(
|
||||
playlist,
|
||||
outcome,
|
||||
runId,
|
||||
true
|
||||
);
|
||||
}
|
||||
const outcome = await this.session.forceRedetect(leaseRef);
|
||||
return outcome.kind === 'success'
|
||||
? playlist
|
||||
: await this.handleOutcome(playlist, outcome, runId, true);
|
||||
} catch {
|
||||
this.offerConnectionRetry(
|
||||
playlist,
|
||||
'connection-redetect-failed',
|
||||
runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
const outcome = await this.session.forceRedetect(leaseRef);
|
||||
return outcome.kind === 'success'
|
||||
? playlist
|
||||
: this.handleOutcome(playlist, outcome, runId, true);
|
||||
}
|
||||
|
||||
async retryPendingPersistence(): Promise<Playlist | undefined> {
|
||||
@@ -125,6 +143,7 @@ export class StalkerConnectionFlowService {
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
this.runId += 1;
|
||||
this.dismissConnectionRetry();
|
||||
this.activeDialog?.close();
|
||||
this.activeDialog = null;
|
||||
await this.discardPending();
|
||||
@@ -141,6 +160,7 @@ export class StalkerConnectionFlowService {
|
||||
return undefined;
|
||||
}
|
||||
const runId = ++this.runId;
|
||||
this.dismissConnectionRetry();
|
||||
await this.discardPending();
|
||||
try {
|
||||
const outcome =
|
||||
@@ -151,13 +171,18 @@ export class StalkerConnectionFlowService {
|
||||
provisionalReason: 'migration',
|
||||
})
|
||||
: request.outcome;
|
||||
return this.handleOutcome(
|
||||
return await this.handleOutcome(
|
||||
request.playlist,
|
||||
outcome,
|
||||
runId,
|
||||
true
|
||||
);
|
||||
} catch {
|
||||
this.offerConnectionRetry(
|
||||
request.playlist,
|
||||
'connection-recovery-failed',
|
||||
runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -246,6 +271,9 @@ export class StalkerConnectionFlowService {
|
||||
outcome.kind === 'ready' ? credentials : undefined;
|
||||
continue;
|
||||
}
|
||||
if (outcome.kind === 'failure') {
|
||||
this.offerConnectionRetry(playlist, outcome.reason, runId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
@@ -350,6 +378,7 @@ export class StalkerConnectionFlowService {
|
||||
}
|
||||
this.activeAttemptRef = null;
|
||||
this.pendingPersistence = null;
|
||||
this.dismissConnectionRetry();
|
||||
if (pending.announceReady) {
|
||||
this.readySubject.next(persisted);
|
||||
}
|
||||
@@ -371,23 +400,23 @@ export class StalkerConnectionFlowService {
|
||||
try {
|
||||
outcome = await this.session.open(persisted);
|
||||
} catch {
|
||||
this.notifyPromotionFailure();
|
||||
this.offerConnectionRetry(
|
||||
persisted,
|
||||
'session-promotion-failed',
|
||||
pending.runId
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (await this.discardOutcomeWhenStale(outcome, pending.runId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (outcome.kind !== 'ready') {
|
||||
const recovered = await this.handleOutcome(
|
||||
return this.handleOutcome(
|
||||
persisted,
|
||||
outcome,
|
||||
pending.runId,
|
||||
pending.announceReady
|
||||
);
|
||||
if (recovered === undefined && pending.runId === this.runId) {
|
||||
this.notifyPromotionFailure();
|
||||
}
|
||||
return recovered;
|
||||
}
|
||||
|
||||
if (pending.announceReady) {
|
||||
@@ -442,6 +471,7 @@ export class StalkerConnectionFlowService {
|
||||
}
|
||||
|
||||
private offerPersistenceRetry(): void {
|
||||
this.dismissConnectionRetry();
|
||||
const ref = this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
|
||||
@@ -455,15 +485,50 @@ export class StalkerConnectionFlowService {
|
||||
.subscribe(() => void this.retryPendingPersistence());
|
||||
}
|
||||
|
||||
private notifyPromotionFailure(): void {
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
|
||||
{ reason: 'session-promotion-failed' }
|
||||
),
|
||||
undefined,
|
||||
{ duration: 10_000 }
|
||||
offerRetry(playlist: Playlist, reason: string): void {
|
||||
this.offerConnectionRetry(playlist, reason, this.runId);
|
||||
}
|
||||
|
||||
private offerConnectionRetry(
|
||||
playlist: Playlist,
|
||||
reason: string,
|
||||
runId: number
|
||||
): void {
|
||||
if (runId !== this.runId) {
|
||||
return;
|
||||
}
|
||||
this.dismissConnectionRetry();
|
||||
const ref = this.snackBar.open(
|
||||
this.connectionFailureMessage(reason),
|
||||
this.translate.instant('HOME.STALKER_PORTAL.RETRY'),
|
||||
{ duration: 120_000 }
|
||||
);
|
||||
this.activeConnectionRetry = ref;
|
||||
ref.onAction()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
if (
|
||||
runId !== this.runId ||
|
||||
this.activeConnectionRetry !== ref
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.activeConnectionRetry = null;
|
||||
void this.forceRedetect(playlist);
|
||||
});
|
||||
}
|
||||
|
||||
private connectionFailureMessage(reason: string): string {
|
||||
const message = this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
|
||||
{ reason }
|
||||
);
|
||||
return message.includes(reason) ? message : `${message} ${reason}`;
|
||||
}
|
||||
|
||||
private dismissConnectionRetry(): void {
|
||||
this.activeConnectionRetry?.dismiss();
|
||||
this.activeConnectionRetry = null;
|
||||
}
|
||||
|
||||
private async discardPending(): Promise<void> {
|
||||
|
||||
+121
-1
@@ -25,6 +25,8 @@ const ACTIVE_PLAYLIST: PlaylistMeta = {
|
||||
const FULL_STALKER_PLAYLIST: PlaylistMeta = {
|
||||
...ACTIVE_PLAYLIST,
|
||||
isFullStalkerPortal: true,
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
stalkerSerialNumber: 'CUSTOMSN123',
|
||||
stalkerDeviceId1: 'DEVICE-ID-1',
|
||||
stalkerDeviceId2: 'DEVICE-ID-2',
|
||||
@@ -89,6 +91,7 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
cancel: jest.fn().mockResolvedValue(undefined),
|
||||
connectionReady$: connectionReady.asObservable(),
|
||||
ensureConnected: jest.fn(async (playlist: PlaylistMeta) => playlist),
|
||||
offerRetry: jest.fn(),
|
||||
};
|
||||
|
||||
const session = {
|
||||
@@ -142,12 +145,19 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
stalkerStore.setSelectedContentType.mockClear();
|
||||
stalkerStore.setSearchPhrase.mockClear();
|
||||
playlistsService.getPlaylistById.mockClear();
|
||||
playlistsService.getPlaylistById.mockImplementation(() =>
|
||||
of(ACTIVE_PLAYLIST)
|
||||
);
|
||||
connectionFlow.cancel.mockClear();
|
||||
connectionFlow.ensureConnected.mockClear();
|
||||
connectionFlow.offerRetry.mockClear();
|
||||
session.activate.mockClear();
|
||||
session.close.mockClear();
|
||||
session.deactivate.mockClear();
|
||||
session.getLeaseRef.mockClear();
|
||||
session.getLeaseRef.mockImplementation(
|
||||
(playlistId: string) => `lease-${playlistId}`
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -258,7 +268,7 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses active Stalker playlist metadata directly when the portal mode is explicit', async () => {
|
||||
it('uses active Stalker playlist metadata directly when its recipe is current', async () => {
|
||||
activePlaylist.set(FULL_STALKER_PLAYLIST);
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
@@ -270,6 +280,30 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a legacy boolean on temporary route metadata shadow the full stored playlist', async () => {
|
||||
activePlaylist.set({
|
||||
...ACTIVE_PLAYLIST,
|
||||
isFullStalkerPortal: false,
|
||||
title: 'Untitled playlist',
|
||||
});
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of(FULL_STALKER_PLAYLIST)
|
||||
);
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
|
||||
PLAYLIST_ID
|
||||
);
|
||||
expect(connectionFlow.ensureConnected).toHaveBeenCalledWith(
|
||||
FULL_STALKER_PLAYLIST
|
||||
);
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
|
||||
FULL_STALKER_PLAYLIST
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose a playlist to catalog resources when connection flow is cancelled', async () => {
|
||||
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
|
||||
|
||||
@@ -279,6 +313,92 @@ describe('StalkerWorkspaceRouteSession', () => {
|
||||
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers a retry instead of exposing a playlist when lease activation fails', async () => {
|
||||
session.activate.mockResolvedValueOnce({
|
||||
kind: 'failure',
|
||||
reason: 'portal-unavailable',
|
||||
requestId: 'activate-failure',
|
||||
retryable: true,
|
||||
stage: 'ready',
|
||||
});
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(connectionFlow.offerRetry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ _id: PLAYLIST_ID }),
|
||||
'portal-unavailable'
|
||||
);
|
||||
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let a stale activation failure close or retry a newer lease for the same playlist', async () => {
|
||||
const staleActivation = createDeferred<{
|
||||
kind: 'failure';
|
||||
reason: string;
|
||||
requestId: string;
|
||||
retryable: boolean;
|
||||
stage: 'ready';
|
||||
}>();
|
||||
let currentLeaseRef = 'lease-stale';
|
||||
session.getLeaseRef.mockImplementation(() => currentLeaseRef);
|
||||
session.activate
|
||||
.mockReturnValueOnce(staleActivation.promise)
|
||||
.mockResolvedValueOnce({
|
||||
action: 'activate',
|
||||
kind: 'success',
|
||||
requestId: 'activate-current',
|
||||
});
|
||||
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
expect(session.activate).toHaveBeenCalledWith('lease-stale');
|
||||
|
||||
router.url = '/workspace/m3u/playlist-2';
|
||||
playlistContext.syncFromUrl.mockReturnValueOnce({
|
||||
inWorkspace: true,
|
||||
playlistId: 'playlist-2',
|
||||
provider: 'm3u',
|
||||
section: null,
|
||||
});
|
||||
routerEvents.next(
|
||||
new NavigationEnd(
|
||||
2,
|
||||
`/workspace/stalker/${PLAYLIST_ID}/vod`,
|
||||
router.url
|
||||
)
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
currentLeaseRef = 'lease-current';
|
||||
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
|
||||
routerEvents.next(
|
||||
new NavigationEnd(
|
||||
3,
|
||||
'/workspace/m3u/playlist-2',
|
||||
router.url
|
||||
)
|
||||
);
|
||||
await flushEffects();
|
||||
expect(session.activate).toHaveBeenLastCalledWith('lease-current');
|
||||
expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ _id: PLAYLIST_ID })
|
||||
);
|
||||
|
||||
staleActivation.resolve({
|
||||
kind: 'failure',
|
||||
reason: 'stale-portal-unavailable',
|
||||
requestId: 'activate-stale-failure',
|
||||
retryable: true,
|
||||
stage: 'ready',
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(session.deactivate).not.toHaveBeenCalledWith('lease-current');
|
||||
expect(session.close).not.toHaveBeenCalledWith('lease-current');
|
||||
expect(connectionFlow.offerRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels a provisional route attempt before leaving the Stalker workspace', async () => {
|
||||
TestBed.inject(StalkerWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
@@ -16,8 +16,13 @@ import {
|
||||
StalkerSessionService,
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { STALKER_RECIPE_CLASSIFIER_VERSION } from '@iptvnator/portal/stalker/protocol';
|
||||
import { PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
type StalkerSessionLeaseRef,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -146,7 +151,8 @@ export class StalkerWorkspaceRouteSession {
|
||||
private async activateAndSetCurrentPlaylist(
|
||||
playlist: Playlist
|
||||
): Promise<void> {
|
||||
if (playlist._id !== this.currentPlaylistId) {
|
||||
const activationGeneration = this.syncGeneration;
|
||||
if (!this.isCurrentActivation(playlist, activationGeneration)) {
|
||||
return;
|
||||
}
|
||||
const leaseRef = this.session.getLeaseRef(playlist._id);
|
||||
@@ -154,28 +160,96 @@ export class StalkerWorkspaceRouteSession {
|
||||
try {
|
||||
const activation = await this.session.activate(leaseRef);
|
||||
if (activation.kind !== 'success') {
|
||||
await this.releasePlaylistSession(playlist._id);
|
||||
const retryEligible = this.isCurrentActivation(
|
||||
playlist,
|
||||
activationGeneration,
|
||||
leaseRef
|
||||
);
|
||||
await this.releaseLeaseSession(leaseRef);
|
||||
this.offerActivationRetry(
|
||||
playlist,
|
||||
activation.kind === 'failure'
|
||||
? activation.reason
|
||||
: `session-activation-${activation.kind}`,
|
||||
activationGeneration,
|
||||
leaseRef,
|
||||
retryEligible
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
await this.releasePlaylistSession(playlist._id);
|
||||
const retryEligible = this.isCurrentActivation(
|
||||
playlist,
|
||||
activationGeneration,
|
||||
leaseRef
|
||||
);
|
||||
await this.releaseLeaseSession(leaseRef);
|
||||
this.offerActivationRetry(
|
||||
playlist,
|
||||
'session-activation-failed',
|
||||
activationGeneration,
|
||||
leaseRef,
|
||||
retryEligible
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (playlist._id === this.currentPlaylistId) {
|
||||
if (
|
||||
this.isCurrentActivation(
|
||||
playlist,
|
||||
activationGeneration,
|
||||
leaseRef
|
||||
)
|
||||
) {
|
||||
await this.stalkerStore.setCurrentPlaylist(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
private offerActivationRetry(
|
||||
playlist: Playlist,
|
||||
reason: string,
|
||||
activationGeneration: number,
|
||||
leaseRef: StalkerSessionLeaseRef,
|
||||
retryEligible: boolean
|
||||
): void {
|
||||
const currentLeaseRef = this.session.getLeaseRef(playlist._id);
|
||||
if (
|
||||
retryEligible &&
|
||||
this.isCurrentActivation(playlist, activationGeneration) &&
|
||||
(currentLeaseRef === undefined || currentLeaseRef === leaseRef)
|
||||
) {
|
||||
this.connectionFlow.offerRetry(playlist, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private async releasePlaylistSession(playlistId: string): Promise<void> {
|
||||
const leaseRef = this.session.getLeaseRef(playlistId);
|
||||
if (leaseRef === undefined) {
|
||||
return;
|
||||
}
|
||||
await this.releaseLeaseSession(leaseRef);
|
||||
}
|
||||
|
||||
private async releaseLeaseSession(
|
||||
leaseRef: StalkerSessionLeaseRef
|
||||
): Promise<void> {
|
||||
await this.session.deactivate(leaseRef).catch(() => undefined);
|
||||
await this.session.close(leaseRef).catch(() => undefined);
|
||||
}
|
||||
|
||||
private isCurrentActivation(
|
||||
playlist: Playlist,
|
||||
activationGeneration: number,
|
||||
leaseRef?: StalkerSessionLeaseRef
|
||||
): boolean {
|
||||
return (
|
||||
activationGeneration === this.syncGeneration &&
|
||||
playlist._id === this.currentPlaylistId &&
|
||||
(leaseRef === undefined ||
|
||||
this.session.getLeaseRef(playlist._id) === leaseRef)
|
||||
);
|
||||
}
|
||||
|
||||
private syncRouteState(section: PortalRailSection | null): void {
|
||||
if (!section) {
|
||||
return;
|
||||
@@ -213,7 +287,7 @@ export class StalkerWorkspaceRouteSession {
|
||||
): Promise<PlaylistMeta | undefined> {
|
||||
const activePlaylist = this.playlistContext.activePlaylist();
|
||||
|
||||
if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) {
|
||||
if (this.hasCurrentStalkerRecipe(playlistId, activePlaylist)) {
|
||||
return activePlaylist;
|
||||
}
|
||||
|
||||
@@ -225,13 +299,15 @@ export class StalkerWorkspaceRouteSession {
|
||||
return storedPlaylist ?? activePlaylist ?? undefined;
|
||||
}
|
||||
|
||||
private hasExplicitStalkerPortalMode(
|
||||
private hasCurrentStalkerRecipe(
|
||||
playlistId: string,
|
||||
playlist: PlaylistMeta | null
|
||||
): playlist is PlaylistMeta {
|
||||
return (
|
||||
playlist?._id === playlistId &&
|
||||
playlist.isFullStalkerPortal !== undefined
|
||||
playlist.stalkerRequestRecipe !== undefined &&
|
||||
playlist.stalkerRecipeClassifierVersion ===
|
||||
STALKER_RECIPE_CLASSIFIER_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user