Files
iptvnator/libs/services/src/lib/server-storage/server-api-client.spec.ts
T
4grayandClaude Opus 5.5 487c98ab96 fix(server-storage): replay edits behind travelling writes in order, hold writes after an offline start
- 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>
2026-10-12 09:31:45 +02:00

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();
});
});