/** * An ended (destroyed) alert is never an alarm. * * The server destroys an alert on delivery, expiry, owner disarm or delete, and * every action then 404s. A client must render that as a terminal ending — no * ring, no pending event, no invented replay — and must not retry an ack for it * forever. */ import AsyncStorage from '@react-native-async-storage/async-storage'; import { configureApi, ApiError } from '../src/services/apiClient'; import { ackAlarm, alarmStatus, isAlertGone } from '../src/services/alarmApi'; import { dropAck, enqueueAck, flushAcks, loadAcks } from '../src/services/pendingAcks'; jest.mock('@react-native-async-storage/async-storage', () => { const store = new Map(); return { __esModule: true, default: { getItem: jest.fn(async (k: string) => store.get(k) ?? null), setItem: jest.fn(async (k: string, v: string) => { store.set(k, v); }), removeItem: jest.fn(async (k: string) => { store.delete(k); }), }, }; }); 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(async () => { await AsyncStorage.removeItem('alarm.pendingAcks.v1'); configureApi({ baseUrl: 'http://instance.test', token: null, refreshToken: null, onUnauthorized: () => {}, onSessionUpdate: () => {}, }); }); afterEach(() => { g.fetch = realFetch; }); describe('ended alerts never ring', () => { it('a 404 check-in is a terminal error, not an offline trigger', async () => { stub(404, { error: { code: 'ERR_ALERT_NOT_FOUND', message: 'alert not found' } }); const err = await alarmStatus('alr_gone').catch(e => e); expect(err).toBeInstanceOf(ApiError); expect(err.status).toBe(404); expect(isAlertGone(err)).toBe(true); expect((err as ApiError).code).toBe('ERR_ALERT_NOT_FOUND'); // Nothing success-shaped was produced, so no caller can ring from it. expect((err as { offlineTrigger?: boolean }).offlineTrigger).toBeUndefined(); }); it('a 404 ack is settled: the queued entry is dropped, not retried', async () => { await enqueueAck({ alertId: 'alr_gone', eventId: 'evt_1', deviceId: 'dev_x' }); stub(404, { error: { code: 'ERR_ALERT_NOT_FOUND', message: 'alert not found' } }); await flushAcks(); expect(await loadAcks()).toEqual([]); }); it('a transport failure keeps the entry queued for the next retry', async () => { await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' }); g.fetch = (async () => { throw new Error('offline'); }) as unknown as typeof fetch; await flushAcks(); expect(await loadAcks()).toEqual([ { alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' }, ]); }); it('a successful ack clears the entry', async () => { await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' }); stub(200, { cleared: 1 }); await flushAcks(); expect(await loadAcks()).toEqual([]); }); }); describe('ackAlarm wire shape', () => { it('sends alertId, eventId and deviceId with deviceId also in the query', async () => { const seen: { url: string; body?: string }[] = []; g.fetch = (async (url: string, init?: RequestInit) => { seen.push({ url, body: init?.body as string | undefined }); return { ok: true, status: 200, text: async () => '{}', json: async () => ({}) }; }) as unknown as typeof fetch; await ackAlarm({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_abc' }); expect(seen[0].url).toBe( 'http://instance.test/api/alarm/action/ackAlarm?deviceId=dev_abc', ); expect(JSON.parse(seen[0].body ?? '{}')).toEqual({ payload: { alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_abc' }, }); }); it('drops a duplicate queued ack', async () => { await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' }); await enqueueAck({ alertId: 'alr_1', eventId: 'evt_1', deviceId: 'dev_x' }); expect(await loadAcks()).toHaveLength(1); await dropAck('alr_1', 'evt_1'); expect(await loadAcks()).toEqual([]); }); });