[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:
2026-10-19 02:30:51 +01:00
parent a29d9b775e
commit 5fa1e8e83e
6 changed files with 674 additions and 81 deletions
+45
View File
@@ -0,0 +1,45 @@
export {};
/**
* Dismiss must silence the phone locally, first, without the owner.
*
* Source contract: JSX/native playback can't be executed headlessly here, so we
* pin the order the code depends on — `stopAlarm()` before any server call or
* event clearing in the acknowledge path — and that playback has no timer that
* could silence it "by itself".
*/
declare const __dirname: string;
const fs: { readFileSync(p: string, enc: string): string } = jest.requireActual('fs');
const path: { join(...parts: string[]): string } = jest.requireActual('path');
const screen = fs.readFileSync(
path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'),
'utf8',
);
const nativeTs = fs.readFileSync(
path.join(__dirname, '..', 'src', 'services', 'alarmNative.ts'),
'utf8',
);
describe('local dismiss contract', () => {
it('silences first and only then drops the live event', () => {
const ackAt = screen.indexOf('const ack = useCallback');
expect(ackAt).toBeGreaterThan(-1);
const ack = screen.slice(ackAt, ackAt + 900);
expect(ack.indexOf('await stopAlarm()')).toBeGreaterThan(-1);
expect(ack.indexOf('await stopAlarm()')).toBeLessThan(ack.indexOf('clearLiveEvent()'));
});
it('stops both audio and vibration through the native bridge', () => {
expect(nativeTs).toContain('stopAlarm');
expect(nativeTs).toContain('AlarmModule');
});
it('does not schedule its own auto-stop in JS', () => {
expect(screen).not.toMatch(/autoStop/i);
// The 1 s interval is the displayed count-up only; it must not stop audio.
const countUp = screen.slice(screen.indexOf('The count-up ticks'), screen.indexOf('The count-up ticks') + 300);
expect(countUp).toContain('setInterval');
expect(countUp).not.toContain('stopAlarm');
});
});
+126
View File
@@ -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([]);
});
});
+92
View File
@@ -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');
});
});