fix(xtream): apply an account-info answer only to the panel it came from

Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 23:49:49 +02:00
1 parent 1c446cd5be
commit e634cdea6e
2 files changed
+68 -12

No files matched your search

@@ -252,6 +252,37 @@ describe('withPortal', () => {
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
});
it('does not patch the store with a late answer when the playlist was edited in place meanwhile', async () => {
let answer!: (value: unknown) => void;
apiService.getAccountInfo.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
})
);
const pending = store.checkPortalStatus();
// Same id, new panel: the in-place edit flow.
const moved: XtreamPlaylistData = {
...PLAYLIST,
serverUrl: 'https://moved.example.com',
};
store.setCurrentPlaylist(moved);
storedPlaylist = {
...storedPlaylist,
serverUrl: moved.serverUrl,
serverTimezone: undefined,
};
answer({
user_info: { auth: 1, exp_date: '0', status: 'Active' },
server_info: { timezone: 'Europe/London' },
});
await expect(pending).resolves.toBe('active');
expect(store.currentPlaylist()).toEqual(moved);
expect(store.portalStatus()).toBe('unavailable');
expect(storedPlaylist.serverTimezone).toBeUndefined();
});
it('does not persist a late answer onto a row whose connection was edited meanwhile', async () => {
let answer!: (value: unknown) => void;
apiService.getAccountInfo.mockReturnValue(
@@ -58,6 +58,26 @@ export function withPortal() {
const dataSource = inject(XTREAM_DATA_SOURCE);
const playlistsService = inject(PlaylistsService);
/**
* Whether a playlist (the store's current one, or the stored
* row) still points at the panel an account-info answer came
* from. The playlist id alone is not enough: an in-place edit
* keeps the id while moving the source, and an answer already on
* the wire for the OLD panel must not describe the new one.
*/
const answersFor = (
candidate: {
serverUrl?: string;
username?: string;
password?: string;
} | null,
credentials: XtreamCredentials
): boolean =>
!!candidate &&
candidate.serverUrl === credentials.serverUrl &&
candidate.username === credentials.username &&
candidate.password === credentials.password;
/**
* The Favorites / Recent catch-up resolver reads the STORED
* playlist row, not this store, so a timezone learned here has
@@ -79,9 +99,7 @@ export function withPortal() {
playlistsService.transformPlaylistMeta(
playlistId,
(playlist) =>
playlist.serverUrl !== credentials.serverUrl ||
playlist.username !== credentials.username ||
playlist.password !== credentials.password ||
!answersFor(playlist, credentials) ||
playlist.serverTimezone === serverTimezone
? null
: { ...playlist, serverTimezone }
@@ -173,15 +191,18 @@ export function withPortal() {
.map((format) => format.trim())
.filter(Boolean)
: undefined;
// The answer belongs to the playlist whose
// credentials were sent: its timezone is persisted
// under THAT id regardless, while the store is
// patched only if that playlist is still the
// selected one — a source switch during the request
// must not hand playlist A's status or clock to
// playlist B.
// The answer belongs to the panel whose credentials
// were sent: its timezone is offered to THAT row
// regardless, while the store is patched only if the
// selected playlist is still that panel — neither a
// source switch nor an in-place edit during the
// request may hand the old panel's status or clock
// to the new one.
const current = store.currentPlaylist();
if (current?.id === playlist.id) {
if (
current?.id === playlist.id &&
answersFor(current, credentials)
) {
patchState(store, {
portalStatus,
currentPlaylist: {
@@ -208,7 +229,11 @@ export function withPortal() {
return portalStatus;
} catch (error) {
logger.error('Error checking portal status', error);
if (store.currentPlaylist()?.id === playlist.id) {
const current = store.currentPlaylist();
if (
current?.id === playlist.id &&
answersFor(current, credentials)
) {
patchState(store, { portalStatus: 'unavailable' });
}
return 'unavailable';