Files
ersatztv/web/src/mediaSources/pinFlowPoll.test.ts
T
timothy 0f0a0d7fe5
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m26s
fix(web): coherent, recoverable Plex authorized-but-no-servers state
A real Plex pin flow reached isAuthorized:true, isLocked:false, servers:[]
(a Plex account that owns/has been granted no eligible servers). The SPA
then showed contradictory state: the pin-flow status claimed "Connected to
Plex." while the Connection card said "Not signed in.", offered Sign in, and
hid Sign out — so the stored authorization could not be removed through the UI.

Root cause: PlexSourceScreen conflated authorization (a stored token,
isAuthorized) with connection (a discovered server, servers.length > 0) by
deriving everything from `connected = servers.length > 0`, and evaluatePinFlow
returned `success` ("Connected to Plex.") for any released-lock authorized
observation regardless of server count.

Fix (pure SPA; sign-out endpoint already works with empty servers):
- pinFlowPoll: add a distinct `authorized-no-servers` terminal status and a
  `hasServers` observation field; the released-lock branch now yields success
  only when a server was discovered, else authorized-no-servers with an honest
  message that distinguishes authorization from discovery.
- PlexSourceScreen: gate Sign in vs Sign out on `hasPlexAccount`
  (isAuthorized || hasServers) instead of the server count, so an authorized
  account can always sign out; add a subtitle + explanatory summary for the
  authorized/empty state; keep the Servers card gated on actual servers.
- Regression tests: the authorized/unlocked/empty transition (pin-flow and
  fresh-reload paths) and sign-out from that state.

fixes #345
2026-07-16 14:15:45 +02:00

97 lines
4.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
PIN_FLOW_POLL_BUDGET_MS,
PIN_FLOW_POLL_INTERVAL_MS,
evaluatePinFlow,
initialPinFlowState,
isPinFlowBudgetExhausted
} from './pinFlowPoll';
describe('pinFlowPoll constants', () => {
it('polls every 2s for up to 150s (design §C1)', () => {
expect(PIN_FLOW_POLL_INTERVAL_MS).toBe(2000);
expect(PIN_FLOW_POLL_BUDGET_MS).toBe(150_000);
});
});
describe('initialPinFlowState', () => {
it('starts in the non-terminal waiting state', () => {
const state = initialPinFlowState();
expect(state.status).toBe('waiting');
expect(state.done).toBe(false);
expect(state.ok).toBe(false);
});
});
describe('evaluatePinFlow — all transitions with injected state', () => {
it('locked & !authorized => waiting (keep polling)', () => {
const state = evaluatePinFlow({ isLocked: true, isAuthorized: false, hasServers: false });
expect(state.status).toBe('waiting');
expect(state.done).toBe(false);
expect(state.ok).toBe(false);
});
it('locked & authorized => finalizing (keep polling, discovering servers)', () => {
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true, hasServers: false });
expect(state.status).toBe('finalizing');
expect(state.done).toBe(false);
expect(state.ok).toBe(false);
});
it('!locked & authorized & servers => success (terminal)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: true });
expect(state.status).toBe('success');
expect(state.done).toBe(true);
expect(state.ok).toBe(true);
});
it('!locked & authorized & NO servers => authorized-no-servers (terminal, recoverable, not ok) — #345', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: false });
expect(state.status).toBe('authorized-no-servers');
expect(state.done).toBe(true); // terminal: the lock has released, polling stops
expect(state.ok).toBe(false); // authorized, but NOT "Connected to Plex."
expect(state.message).not.toBe('Connected to Plex.');
expect(state.message).toMatch(/no eligible servers/i);
});
it('!locked & !authorized => timeout (terminal, ended without auth)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false, hasServers: false });
expect(state.status).toBe('timeout');
expect(state.done).toBe(true);
expect(state.ok).toBe(false);
});
it('locked & budget exhausted => budget-exhausted (terminal, not a failure)', () => {
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true, hasServers: false }, { budgetExhausted: true });
expect(state.status).toBe('budget-exhausted');
expect(state.done).toBe(true);
expect(state.ok).toBe(false);
});
it('budget exhaustion is ignored once the lock has released (success still wins)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: true }, { budgetExhausted: true });
expect(state.status).toBe('success');
expect(state.ok).toBe(true);
});
it('budget exhaustion is ignored once the lock has released (authorized-no-servers still wins)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: false }, { budgetExhausted: true });
expect(state.status).toBe('authorized-no-servers');
expect(state.ok).toBe(false);
});
it('budget exhaustion is ignored once the lock has released (timeout still wins)', () => {
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false, hasServers: false }, { budgetExhausted: true });
expect(state.status).toBe('timeout');
});
});
describe('isPinFlowBudgetExhausted', () => {
it('is false until the budget is reached and true at/after it', () => {
expect(isPinFlowBudgetExhausted(0)).toBe(false);
expect(isPinFlowBudgetExhausted(PIN_FLOW_POLL_BUDGET_MS - 1)).toBe(false);
expect(isPinFlowBudgetExhausted(PIN_FLOW_POLL_BUDGET_MS)).toBe(true);
expect(isPinFlowBudgetExhausted(PIN_FLOW_POLL_BUDGET_MS + 5000)).toBe(true);
});
});