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
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>
288 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|