mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
- An edit queued behind a travelling write keeps its own before → after and carries the travelling write's edit as `prior`, also in the saved writes. A conflict replays both in order, so adding and then removing a favorite removes it whether or not the first write landed, and a lost write is not dropped. - Page-hide writes now report their new tag or a 412 like normal writes: a confirmed one rebases the next edit, a conflict is replayed. - After an offline start from a remembered instance, writes are held and the retry only checks /instance before reloading, so saved writes never reach another database behind the same address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
6.8 KiB
TypeScript
200 lines
6.8 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('reports how a keepalive write ended', async () => {
|
|
const ok = jest.fn().mockResolvedValue(response(200, { tag: 't2' }));
|
|
await expect(
|
|
client(ok).sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
|
|
).resolves.toEqual({ status: 'saved', tag: 't2' });
|
|
expect(ok.mock.calls[0][1]).toMatchObject({ keepalive: true });
|
|
|
|
const conflict = jest.fn().mockResolvedValue(
|
|
response(412, {
|
|
code: 'conflict',
|
|
message: 'changed',
|
|
current: { value: [2], tag: 't3' },
|
|
})
|
|
);
|
|
await expect(
|
|
client(conflict).sendKeepalive('PUT', '/user-state/a/b', {
|
|
value: 1,
|
|
})
|
|
).resolves.toEqual({
|
|
status: 'conflict',
|
|
current: { value: [2], tag: 't3' },
|
|
});
|
|
|
|
const refused = jest
|
|
.fn()
|
|
.mockRejectedValue(new TypeError('body too large'));
|
|
await expect(
|
|
client(refused).sendKeepalive('DELETE', '/user-state/a/b')
|
|
).resolves.toEqual({ status: 'failed' });
|
|
});
|
|
|
|
it('holds writes, not reads, until the instance is checked', async () => {
|
|
const fetchFn = jest.fn().mockResolvedValue(response(200, {}));
|
|
const held = new ServerApiClient({
|
|
baseUrl: '/api',
|
|
status,
|
|
fetchFn: fetchFn as unknown as typeof fetch,
|
|
holdWrites: true,
|
|
});
|
|
|
|
await expect(
|
|
held.sendJson('PUT', '/user-state/a/b', { value: 1 })
|
|
).rejects.toMatchObject({ status: 0, code: 'network' });
|
|
await expect(
|
|
held.sendKeepalive('DELETE', '/user-state/a/b')
|
|
).resolves.toEqual({ status: 'failed' });
|
|
await held.getJson('/instance');
|
|
|
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance');
|
|
});
|
|
|
|
it('reports offline when a body is cut off after the headers', async () => {
|
|
const cut = {
|
|
...response(200),
|
|
json: async () => {
|
|
throw new TypeError('network error');
|
|
},
|
|
} as unknown as Response;
|
|
|
|
await expect(
|
|
client(jest.fn().mockResolvedValue(cut)).getJson('/user-state')
|
|
).rejects.toMatchObject({ status: 0, code: 'network' });
|
|
expect(status.reportOffline).toHaveBeenCalled();
|
|
|
|
// Malformed JSON is an answer, not an outage.
|
|
status.reportOffline.mockClear();
|
|
const malformed = {
|
|
...response(200),
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected token');
|
|
},
|
|
} as unknown as Response;
|
|
await expect(
|
|
client(jest.fn().mockResolvedValue(malformed)).getJson('/x')
|
|
).rejects.toBeInstanceOf(SyntaxError);
|
|
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();
|
|
});
|
|
});
|