mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
- Moving browser playlists matches them to the server's by id or source and carries the legacy Xtream maps under the server id; it no longer goes through the backup format, whose PWA export has no Xtream user data. A partial move stays on offer. - User-data batches go out in order, and a re-read keeps entries edited while it was in flight. - A keepPayload write keeps its cached copy only when the server replaced the version its payload came from (new X-Replaced-ETag header). - 502/503/504 from the proxy count as offline. - The start-up screen lets the sign-in page through while a token is required. - Settings → Server offers a reload after a move. - The probe detects Electron through RuntimeCapabilitiesService. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
4.0 KiB
TypeScript
121 lines
4.0 KiB
TypeScript
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token';
|
|
import { ServerApiClient, ServerApiError } from './server-api-client';
|
|
|
|
// jsdom has Headers but no Response or fetch.
|
|
function response(status: number, body?: unknown) {
|
|
return {
|
|
status,
|
|
ok: status >= 200 && status < 300,
|
|
statusText: '',
|
|
headers: new Headers(),
|
|
json: async () => body,
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe('ServerApiClient', () => {
|
|
const status = {
|
|
reportSuccess: jest.fn(),
|
|
reportOffline: jest.fn(),
|
|
reportUnauthorized: jest.fn(),
|
|
};
|
|
const onWriteFailure = jest.fn();
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
localStorage.clear();
|
|
});
|
|
|
|
function client(fetchFn: jest.Mock) {
|
|
return new ServerApiClient({
|
|
baseUrl: '/api/',
|
|
status,
|
|
onWriteFailure,
|
|
fetchFn: fetchFn as unknown as typeof fetch,
|
|
});
|
|
}
|
|
|
|
it('calls /v1 with the token and reports success', async () => {
|
|
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't');
|
|
const fetchFn = jest
|
|
.fn()
|
|
.mockResolvedValue(response(200, { ok: true }));
|
|
|
|
await expect(client(fetchFn).getJson('/instance')).resolves.toEqual({
|
|
ok: true,
|
|
});
|
|
|
|
const [url, init] = fetchFn.mock.calls[0];
|
|
expect(url).toBe('/api/v1/instance');
|
|
expect(init.headers).toMatchObject({ Authorization: 'Bearer t' });
|
|
expect(status.reportSuccess).toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports offline on a network error and announces failed writes', async () => {
|
|
const fetchFn = jest
|
|
.fn()
|
|
.mockRejectedValue(new TypeError('Failed to fetch'));
|
|
|
|
await expect(
|
|
client(fetchFn).sendJson('PUT', '/playlists/a', {})
|
|
).rejects.toMatchObject({ status: 0, code: 'network' });
|
|
expect(status.reportOffline).toHaveBeenCalled();
|
|
expect(onWriteFailure).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reports offline when a proxy answers for a down backend', async () => {
|
|
for (const code of [502, 503, 504]) {
|
|
const fetchFn = jest
|
|
.fn()
|
|
.mockResolvedValue(response(code, 'Bad Gateway'));
|
|
|
|
await expect(
|
|
client(fetchFn).getJson('/user-state')
|
|
).rejects.toMatchObject({ status: code });
|
|
}
|
|
expect(status.reportOffline).toHaveBeenCalledTimes(3);
|
|
|
|
// A backend error is not an outage.
|
|
status.reportOffline.mockClear();
|
|
await expect(
|
|
client(
|
|
jest.fn().mockResolvedValue(response(500, { code: 'x' }))
|
|
).getJson('/user-state')
|
|
).rejects.toMatchObject({ status: 500 });
|
|
expect(status.reportOffline).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears the token and reports unauthorized on 401', async () => {
|
|
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old');
|
|
const fetchFn = jest
|
|
.fn()
|
|
.mockResolvedValue(response(401, { code: 'unauthorized' }));
|
|
|
|
await expect(
|
|
client(fetchFn).getJson('/playlists')
|
|
).rejects.toBeInstanceOf(ServerApiError);
|
|
expect(
|
|
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
|
|
).toBeNull();
|
|
expect(status.reportUnauthorized).toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes 304 through and maps error bodies', async () => {
|
|
const fetchFn = jest
|
|
.fn()
|
|
.mockResolvedValueOnce(response(304))
|
|
.mockResolvedValueOnce(
|
|
response(409, { message: 'x', code: 'payload_missing' })
|
|
);
|
|
const api = client(fetchFn);
|
|
|
|
expect((await api.request('/playlists/a')).status).toBe(304);
|
|
await expect(
|
|
api.sendJson('PUT', '/playlists/a', {})
|
|
).rejects.toMatchObject({
|
|
status: 409,
|
|
code: 'payload_missing',
|
|
});
|
|
expect(onWriteFailure).not.toHaveBeenCalled();
|
|
});
|
|
});
|