Files
Admin dcfc9a5df8 [feat](client): scan an instance-relative join payload
The service mints /api/alarm/a/{alertId}#{token} with no host, so the QR renders against the configured instance and keeps working when the instance moves or is reached by another name. The scanner now matches path shape and fragment and never the host, so legacy alarm.dcntrlized.org codes and other instances codes still join; error copy distinguishes a malformed code from a wrong-alert token.
2026-10-19 02:34:20 +01:00

82 lines
2.6 KiB
TypeScript

/**
* Error plumbing: the service answers `{"error":{"code":…,"message":…}}`, and
* the join path has to tell the user something true without naming a mechanism
* it cannot observe. These tests pin both halves.
*/
import { ApiError, configureApi, post } from '../src/services/apiClient';
import { joinErrorMessage } from '../src/services/alarmApi';
const g = globalThis as unknown as { fetch: typeof fetch };
const realFetch = g.fetch;
function stubFetch(body: string, status = 400): void {
g.fetch = (async () => ({
ok: false,
status,
text: async () => body,
})) as unknown as typeof fetch;
}
beforeEach(() => {
configureApi({
baseUrl: 'http://instance.test',
token: null,
refreshToken: null,
onUnauthorized: () => {},
onSessionUpdate: () => {},
});
});
afterEach(() => {
g.fetch = realFetch;
});
describe('apiClient error unwrapping', () => {
it('extracts the service error code and the clean message', async () => {
stubFetch(
JSON.stringify({
error: { code: 'ERR_ALERT_TOKEN_INVALID', message: 'invalid subscribe token' },
}),
);
await expect(post('joinAlert', {})).rejects.toMatchObject({
name: 'ApiError',
status: 400,
code: 'ERR_ALERT_TOKEN_INVALID',
message: 'invalid subscribe token',
});
});
it('keeps a non-JSON body as the message and reports no code', async () => {
stubFetch('Bad Gateway', 502);
await expect(post('joinAlert', {})).rejects.toMatchObject({
status: 502,
code: null,
message: 'Bad Gateway',
});
});
});
describe('joinErrorMessage', () => {
it('says the code is not valid for this alert, without inventing a replacement', () => {
// JoinAlert returns this one code for any code that does not belong to the
// alert, so the client cannot say which happened — and with rotation gone
// there is no "new one" to ask for.
const err = new ApiError(400, 'invalid subscribe token', 'ERR_ALERT_TOKEN_INVALID');
const copy = joinErrorMessage(err);
expect(copy).toBe('This code is not valid for this alert.');
expect(copy).not.toMatch(/expired/i);
expect(copy).not.toMatch(/new one|rotate|rotated/i);
});
it('passes through any other server message', () => {
expect(joinErrorMessage(new ApiError(404, 'alert not found', 'ERR_NOT_FOUND'))).toBe(
'alert not found',
);
});
it('falls back for errors that are not ApiError instances', () => {
expect(joinErrorMessage(new Error('boom'))).toBe('boom');
expect(joinErrorMessage(undefined)).toBe('Could not subscribe this device.');
});
});