Files
ersatztv/web/src/api/troubleshoot.test.ts
T
timothyandClaude Opus 4.8 ef2bd65c27
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): #286 — mount the whole /api surface at /api/v1
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.

Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.

Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).

Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.

Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.

fixes #286
refs #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:30:20 +02:00

288 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { subscribeUnauthorized } from './auth';
import {
downloadTroubleshootingArchive,
downloadTroubleshootingMediaSample,
getTroubleshootingInfo,
getTroubleshootingPlaybackStatus,
getTroubleshootingStreamSelectors,
getTroubleshootingSubtitles,
startTroubleshootingPlayback,
validateSequentialSchedule,
type StartTroubleshootingPlaybackBody
} from './troubleshoot';
function headerValue(init: RequestInit | undefined, name: string): string | undefined {
const headers = init?.headers as Record<string, string> | undefined;
if (!headers) {
return undefined;
}
const match = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase());
return match ? headers[match] : undefined;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const sampleInfo = {
generalJson: '{"Version":"1.2.3"}',
nvidiaCapabilities: 'nvidia output',
qsvCapabilities: null,
vaapiCapabilities: null,
videoToolboxCapabilities: null
};
describe('getTroubleshootingInfo', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/v1/troubleshoot/info and returns the info payload', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleInfo), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(getTroubleshootingInfo()).resolves.toMatchObject({ nvidiaCapabilities: 'nvidia output' });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/troubleshoot/info');
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
);
await expect(getTroubleshootingInfo()).rejects.toMatchObject({ status: 500 });
});
});
describe('validateSequentialSchedule', () => {
const sampleResult = { isValid: true, messages: [], json: '{}' };
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/v1/troubleshoot/validate-schedule with the yaml and isImport body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResult), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(validateSequentialSchedule('content: []', true)).resolves.toMatchObject({ isValid: true });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/troubleshoot/validate-schedule');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ yaml: 'content: []', isImport: true });
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 400, title: 'Validation failed' }), {
headers: { 'Content-Type': 'application/json' },
status: 400
})
);
await expect(validateSequentialSchedule('', false)).rejects.toMatchObject({ status: 400 });
});
});
describe('troubleshooting playback wrappers', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs the stream selectors list', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(['a', 'b']));
await expect(getTroubleshootingStreamSelectors()).resolves.toEqual(['a', 'b']);
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/troubleshoot/playback/stream-selectors');
});
it('GETs the subtitle list for a media item id', async () => {
const fetchSpy = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse([{ id: 3, language: 'eng', title: 'English', codec: 'subrip' }]));
await expect(getTroubleshootingSubtitles(42)).resolves.toMatchObject([{ id: 3 }]);
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/troubleshoot/playback/subtitles/42');
});
it('GETs the playback status', async () => {
const fetchSpy = vi
.spyOn(window, 'fetch')
.mockResolvedValue(jsonResponse({ state: 'completed', exitCode: 0, speed: 1.2, logs: 'ok' }));
await expect(getTroubleshootingPlaybackStatus()).resolves.toMatchObject({ state: 'completed', speed: 1.2 });
expect(fetchSpy.mock.calls[0][0]).toBe('/api/v1/troubleshoot/playback/status');
});
});
describe('startTroubleshootingPlayback', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('POSTs /api/v1/troubleshoot/playback/start with the mapped body and a CSRF header, returning the url', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
jsonResponse({ url: '/iptv/session/.troubleshooting/live.m3u8' })
);
// Arrays are carried through, and the mutually-exclusive pair is preserved as the caller mapped it
// (streamSelector set → subtitleId null).
const body: StartTroubleshootingPlaybackBody = {
mediaItem: 5,
channel: 0,
ffmpegProfile: 2,
streamingMode: 'HttpLiveStreamingSegmenter',
watermark: [10, 11],
graphicsElement: [20],
streamSelector: 'selector-a',
subtitleId: null,
seekSeconds: 1200,
start: null
};
await expect(startTroubleshootingPlayback(body)).resolves.toEqual({
url: '/iptv/session/.troubleshooting/live.m3u8'
});
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/troubleshoot/playback/start');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(headerValue(init, 'X-Csrf')).toBe('1');
expect(JSON.parse(String(init?.body))).toEqual(body);
});
it('rejects with the ApiError status (409) so the screen can surface "another session running"', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 409, title: 'Conflict', detail: 'Another session is running.' }), {
headers: { 'Content-Type': 'application/problem+json' },
status: 409
})
);
await expect(
startTroubleshootingPlayback({
mediaItem: 5,
channel: 0,
ffmpegProfile: 1,
streamingMode: 'HttpLiveStreamingSegmenter',
watermark: [],
graphicsElement: [],
streamSelector: null,
subtitleId: null,
seekSeconds: 0,
start: null
})
).rejects.toMatchObject({ status: 409, detail: 'Another session is running.' });
});
});
describe('troubleshooting download helpers', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
// Engine-neutral fake: a real `new Response(new Blob(...))` fails in CI (undici) because a
// jsdom Blob has no `.stream()` for undici's Response body to consume ("object.stream is not a
// function"). Passing settings through a plain object with an async `blob()` avoids any
// Blob→Response interop crossing; `URL.createObjectURL` is stubbed so the blob body is inert.
function zipResponse(filename: string): Response {
return {
ok: true,
status: 200,
headers: new Headers({
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`
}),
blob: async () => new Blob(['zip-bytes'])
} as unknown as Response;
}
function stubBrowserDownload() {
const createObjectURL = vi.fn(() => 'blob:mock');
const revokeObjectURL = vi.fn();
// jsdom does not implement these on URL; install spies so the anchor-click download path runs.
(URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL;
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL;
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
return { createObjectURL, revokeObjectURL, clickSpy };
}
it('POSTs (not GETs) the archive with a CSRF header and triggers a download', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(zipResponse('ersatztv-troubleshooting-123.zip'));
const { createObjectURL, revokeObjectURL, clickSpy } = stubBrowserDownload();
await downloadTroubleshootingArchive();
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/troubleshoot/playback/archive');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(headerValue(init, 'X-Csrf')).toBe('1');
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
});
it('POSTs the media sample for a given id with a CSRF header', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(zipResponse('ersatztv-media-sample-123.zip'));
stubBrowserDownload();
await downloadTroubleshootingMediaSample(42);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/troubleshoot/playback/sample/42');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(headerValue(init, 'X-Csrf')).toBe('1');
});
it('throws an ApiError on a non-ok download response', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 404, title: 'Not Found' }), {
headers: { 'Content-Type': 'application/problem+json' },
status: 404
})
);
stubBrowserDownload();
await expect(downloadTroubleshootingArchive()).rejects.toMatchObject({ status: 404 });
});
it('fires the global 401 signal on an unauthorized download response', async () => {
// downloadViaPost raw-fetches (binary body), bypassing the central client's 401 handling, so it
// must emit notifyUnauthorized() itself — an expired session mid-download should raise the global
// re-login banner, not just an inline error.
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 401, title: 'Unauthorized' }), {
headers: { 'Content-Type': 'application/problem+json' },
status: 401
})
);
stubBrowserDownload();
const onUnauthorized = vi.fn();
const unsubscribe = subscribeUnauthorized(onUnauthorized);
await expect(downloadTroubleshootingArchive()).rejects.toMatchObject({ status: 401 });
expect(onUnauthorized).toHaveBeenCalledTimes(1);
unsubscribe();
});
});