The server ledger is cleared by an explicit ack, never by the check-in response, so a dismissal that cannot reach the service must be persisted and retried or the device rings again later. Add a durable pending-ack queue (404 means the alert ended: settled, drop it), map the 409 ERR_ALARM_TRIGGERED_OFFLINE check-in into a normal trigger for the same takeover, and make a 404 an ending rather than a missed alarm. This also carries the expiry/endings state the screens render.
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
/**
|
|
* Durable delivery: `alarmStatus` can answer 409 `ERR_ALARM_TRIGGERED_OFFLINE`
|
|
* with the alarm frame in the body. That is an alarm signal, not a failure —
|
|
* the client must ring the same takeover and must not render an error.
|
|
*/
|
|
import { configureApi, ApiError, OfflineAlarmError, post } from '../src/services/apiClient';
|
|
import { alarmStatus } from '../src/services/alarmApi';
|
|
|
|
const g = globalThis as unknown as { fetch: typeof fetch };
|
|
const realFetch = g.fetch;
|
|
|
|
function stub(status: number, body: unknown): void {
|
|
g.fetch = (async () => ({
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => JSON.stringify(body),
|
|
json: async () => body,
|
|
})) as unknown as typeof fetch;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
configureApi({
|
|
baseUrl: 'http://instance.test',
|
|
token: null,
|
|
refreshToken: null,
|
|
onUnauthorized: () => {},
|
|
onSessionUpdate: () => {},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
g.fetch = realFetch;
|
|
});
|
|
|
|
describe('ERR_ALARM_TRIGGERED_OFFLINE', () => {
|
|
it('is returned as a success-shaped status carrying the frame', async () => {
|
|
stub(409, {
|
|
error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'alarm fired while offline' },
|
|
alarm: {
|
|
type: 'alarm',
|
|
alertId: 'alr_1',
|
|
eventId: 'evt_7',
|
|
action: 'trigger',
|
|
message: 'Fire drill',
|
|
serverTime: '2026-10-19T00:00:00Z',
|
|
},
|
|
});
|
|
await expect(alarmStatus('alr_1')).resolves.toEqual({
|
|
active: true,
|
|
action: 'trigger',
|
|
eventId: 'evt_7',
|
|
message: 'Fire drill',
|
|
serverTime: '2026-10-19T00:00:00Z',
|
|
offlineTrigger: true,
|
|
});
|
|
});
|
|
|
|
it('accepts the frame at the top level too', async () => {
|
|
stub(409, {
|
|
error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'late' },
|
|
alertId: 'alr_1',
|
|
eventId: 'evt_8',
|
|
action: 'trigger',
|
|
});
|
|
await expect(alarmStatus('alr_1')).resolves.toMatchObject({
|
|
active: true,
|
|
eventId: 'evt_8',
|
|
offlineTrigger: true,
|
|
});
|
|
});
|
|
|
|
it('does not let the generic HTTP path swallow it', async () => {
|
|
stub(409, {
|
|
error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'late' },
|
|
alarm: { alertId: 'alr_1', eventId: 'evt_9' },
|
|
});
|
|
await expect(post('alarmStatus', {})).rejects.toBeInstanceOf(OfflineAlarmError);
|
|
});
|
|
|
|
it('refuses to ring a 409 that claims the code but carries no frame', async () => {
|
|
stub(409, { error: { code: 'ERR_ALARM_TRIGGERED_OFFLINE', message: 'late' } });
|
|
await expect(alarmStatus('alr_1')).rejects.toBeInstanceOf(OfflineAlarmError);
|
|
});
|
|
|
|
it('keeps any other 409 a plain error', async () => {
|
|
stub(409, { error: { code: 'ERR_ALERT_ALREADY_JOINED', message: 'already joined' } });
|
|
const err = await alarmStatus('alr_1').catch(e => e);
|
|
expect(err).toBeInstanceOf(ApiError);
|
|
expect(err).not.toBeInstanceOf(OfflineAlarmError);
|
|
expect(err.code).toBe('ERR_ALERT_ALREADY_JOINED');
|
|
});
|
|
});
|