[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,126 @@
|
||||
/**
|
||||
* 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<string, string>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user