[feat](client): make delivery durable with an offline check-in and ack queue
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.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user