/** * A dismissed alarm must stay dismissed. * * The service keeps an alert "triggered" until the owner disarms it or every * subscriber acknowledges, so every status poll, check-in and socket replay keeps * offering the same trigger to a device that already dismissed it. These tests * pin the client's memory of dismissals (what is suppressed, and what must still * ring), plus the two wiring facts that make dismissal reach the service at all. */ // The ack queue module owns AsyncStorage at module load; the storage package is // ESM-only and this is a headless run, so the tiny surface mergeAck needs is // stubbed (nothing here reads or writes real storage). jest.mock('@react-native-async-storage/async-storage', () => ({ __esModule: true, default: { getItem: async () => null, setItem: async () => {}, removeItem: async () => {}, }, })); import { createDismissals } from '../src/lib/dismissals'; import { isDeviceTokenRefused, isAlertGone } from '../src/services/alarmApi'; import { ApiError } from '../src/services/apiClient'; import { mergeAck, type PendingAck } from '../src/services/pendingAcks'; declare const __dirname: string; const fs = require('fs') as { readFileSync(p: string, enc: string): string }; const path = require('path') as { join(...parts: string[]): string }; const EVENT = '11111111-1111-1111-1111-111111111111'; const NEXT = '22222222-2222-2222-2222-222222222222'; describe('dismissal memory', () => { it('suppresses the event this device dismissed and nothing else', () => { const d = createDismissals(); d.mark('a', EVENT); expect(d.suppress('a', EVENT, false)).toBe(true); expect(d.suppress('a', NEXT, false)).toBe(false); expect(d.suppress('b', EVENT, false)).toBe(false); }); it('pins an unnamed dismissal to the trigger it actually covered', () => { const d = createDismissals(); d.mark('a'); // native launch: the event id was never known expect(d.suppress('a', EVENT, false)).toBe(true); expect(d.remembered('a')).toEqual([EVENT]); // Once pinned, a later trigger is news again. expect(d.suppress('a', NEXT, false)).toBe(false); }); it('keeps suppressing while the event id is still unknown', () => { const d = createDismissals(); d.mark('a'); expect(d.suppress('a', undefined, false)).toBe(true); expect(d.suppress('a', undefined, false)).toBe(true); }); it('lets a live socket frame clear the memory — a new trigger must ring', () => { const d = createDismissals(); d.mark('a', EVENT); expect(d.suppress('a', EVENT, true)).toBe(false); expect(d.suppress('a', EVENT, false)).toBe(false); }); it('seeds from the durable ack queue so a replay before the flush is silent', () => { const d = createDismissals(); d.seed([ { alertId: 'a', eventId: EVENT }, { alertId: 'b' }, ]); expect(d.suppress('a', EVENT, false)).toBe(true); expect(d.suppress('b', NEXT, false)).toBe(true); }); }); describe('dismissal reaches the service', () => { const screen = fs.readFileSync( path.join(__dirname, '..', 'src', 'screens', 'LiveAlarmScreen.tsx'), 'utf8', ); const context = fs.readFileSync( path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'), 'utf8', ); it('the takeover acks on dismiss, before it forgets the event', () => { const at = screen.indexOf('const ack = useCallback'); const body = screen.slice(at, at + 1400); expect(body).toContain('acknowledgeAlarm('); expect(body.indexOf('acknowledgeAlarm(')).toBeLessThan(body.indexOf('clearLiveEvent()')); }); it('the ack is queued durably, not fired and forgotten', () => { expect(context).toContain('enqueueAck('); expect(context).toContain('flushAcks('); }); it('an unnamed dismissal is queued without an eventId', () => { // The service then clears the subscription's whole outstanding set. expect(context).toContain('eventId: known'); expect(context).toContain("const known = eventId ?? lastStatus?.eventId ?? undefined"); }); }); describe('a dead device credential is terminal, not retryable', () => { it('401 from a device-token call means the subscription is gone', () => { expect(isDeviceTokenRefused(new ApiError(401, 'invalid device token', 'ERR_UNAUTHORIZED'))).toBe( true, ); expect(isAlertGone(new ApiError(404, 'gone', 'ERR_NOT_FOUND'))).toBe(true); expect(isDeviceTokenRefused(new ApiError(500, 'boom'))).toBe(false); }); }); describe('ack queue without an event id', () => { it('merges an unnamed dismissal with itself only', () => { const unnamed: PendingAck = { alertId: 'a', deviceId: 'dev' }; const named: PendingAck = { alertId: 'a', eventId: EVENT, deviceId: 'dev' }; const once = mergeAck([], unnamed); expect(mergeAck(once, { ...unnamed })).toHaveLength(1); expect(mergeAck(once, named)).toHaveLength(2); }); });