fix(552): don't reload the stale-token URL on preview Retry (review regression)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m41s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 17m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Re-review of 60a0c505 caught a regression the prior fix introduced: onRetry cleared
the token cache and bumped playToken but kept the old resolvedSrc, so HlsPlayer
reloaded the stale-token URL before the remint resolved — a duplicate manifest
session and a stale 401 that could stick the panel as failed even after the fresh
stream succeeded.

Null resolvedSrc in onRetry before bumping playToken so the player unmounts until the
async effect resolves the freshly-minted URL. Added a controlled-async test proving
the stale-token URL is never reloaded and the retry loads the new token (validated by
negative control: the test fails with the fix removed, and only that test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 17:42:15 +02:00
co-authored by Claude Opus 4.8
parent 60a0c50578
commit ce30bbbb32
2 changed files with 28 additions and 0 deletions
@@ -235,6 +235,29 @@ describe('ChannelPreviewPanel', () => {
expect(resetIptvTokenCacheMock).toHaveBeenCalled();
});
it('on Retry re-mints the token and never reloads the stale-token URL', async () => {
// withIptvToken returns a DIFFERENT token per call (simulating a remint after resetIptvTokenCache).
let n = 0;
withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=t${++n}`));
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(1, '/iptv/channel/12.1.m3u8?access_token=t1');
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await flush();
// Exactly two loads total: the initial (t1) and the retry (t2). The stale-token URL (t1) must NOT be
// reloaded when playToken bumps — the player unmounts until the fresh URL resolves. A regression that
// kept resolvedSrc across the bump would produce a third loadSource with the stale t1 URL.
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8?access_token=t2');
expect(hlsMock.loadSource.mock.calls.filter((c) => c[0] === '/iptv/channel/12.1.m3u8?access_token=t1')).toHaveLength(1);
});
it('clicking Retry clears a prior error', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
@@ -118,6 +118,11 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
// token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since load) can't wedge
// playback. An actually-expired token would refresh on its own, but this also covers those edges (#552).
resetIptvTokenCache();
// Null resolvedSrc FIRST so the player unmounts and does not reload the OLD tokened URL when playToken
// bumps below — the fresh URL comes from the async effect after withIptvToken re-mints. Skipping this
// would load the stale token once (duplicate session / a stale 401 that sticks the panel as failed even
// after the fresh stream succeeds).
setResolvedSrc(null);
failedRef.current = false;
setError(null);
setState('starting');