/** * 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('does not let a live socket frame undo a dismissal', () => { // Measured: after dismissing, a queued native takeover replayed the trigger // down the socket and the siren came back. The alert is triggered once, so // a second "live" frame for a dismissed alert is the same alarm. const d = createDismissals(); d.mark('a', EVENT); expect(d.suppress('a', EVENT, true)).toBe(true); expect(d.suppress('a', EVENT, false)).toBe(true); }); 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('a dismissal must survive the app being killed', () => { it('queues the ack even before the device identity is loaded', () => { // Measured against the service: a device-token ack with no deviceId is // accepted (the credential identifies the subscription). `acknowledgeAlarm` // used to `return` when `deviceId` was still null, silently dropping the // dismissal — the alarm then owed the ledger again and rang on reopen. const context = fs.readFileSync( path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'), 'utf8', ); const at = context.indexOf('const acknowledgeAlarm = useCallback'); const body = context.slice(at, at + 1600); expect(body).not.toContain('if (!deviceId) return;'); expect(body).toContain('enqueueAck('); expect(body).toContain('deviceId: deviceId ?? undefined'); }); it('does not raise an alarm before the queued dismissals are known', () => { const context = fs.readFileSync( path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'), 'utf8', ); const at = context.indexOf('const handleTrigger = useCallback'); const body = context.slice(at, at + 900); expect(body).toContain('dismissalsReadyRef.current'); // …and the armed service must not start listening before that either. const armAt = context.indexOf('const armedAttemptRef'); const arm = context.slice(armAt, armAt + 900); expect(arm).toContain('if (!dismissalsReady) return;'); }); it('unsubscribes the device as part of acknowledging', () => { // Owner ruling: a subscriber that acknowledged is done with the alarm, so // the acknowledgement drops the subscription instead of leaving a live // address that rings again on the next launch. const context = fs.readFileSync( path.join(__dirname, '..', 'src', 'state', 'AlarmContext.tsx'), 'utf8', ); const at = context.indexOf('const acknowledgeAlarm = useCallback'); const body = context.slice(at, at + 2600); expect(body).toContain('api.removeSubscription('); expect(body).toContain("markEnded(alertId, 'left')"); // The ack settles the ledger *before* the row goes, so a failed removal can // never leave the debt behind. expect(body.indexOf('flushAcks(')).toBeLessThan(body.indexOf('api.removeSubscription(')); }); it('flushes a queued ack that has no device id of its own', () => { const source = fs.readFileSync( path.join(__dirname, '..', 'src', 'services', 'pendingAcks.ts'), 'utf8', ); expect(source).toContain('deviceId?: string;'); expect(source).toContain('flushAcks(fallbackDeviceId?: string)'); expect(source).toContain('ack.deviceId ?? fallbackDeviceId'); }); }); 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); }); });