[fix](client): arm in the background, make dismiss final, and leave the takeover
The reported failures are one missing acknowledgement and one missing service start. - Alarms only fired with the app in focus: the armed WebSocket lives in the native foreground service, and nothing started it — the only entries were a button on one alert screen and a toggle in Settings. The provider now arms the active subscription itself (asks for POST_NOTIFICATIONS, uses the device token the service can hold, throttles a refused start to once per five minutes), and Home says whether the background listener is really running instead of presenting the on-screen socket as coverage. - Dismissed alarms came back: the takeover never sent the ack. acknowledgeAlarm existed and was never called, so the service still believed this device owed the alarm and replayed it on every connect/poll. The acknowledge path now persists the dismissal (durably, retried) and records it locally, so a status report of the trigger it already dismissed cannot raise the takeover again. The record tolerates a dismissal with no event id (the native launch carries only the alert) and is pinned to the concrete event as soon as one is known. - No way back from the takeover: acknowledge now shows a Back to alerts action, hardware back works once the device is silent, and the screen leaves by itself when the alert ends for this device. - A destroyed alert answers 401 to its device token (the subscription went with it). That is terminal: acknowledgements and the status poll stop retrying the dead credential and mark the subscription ended instead of looping. - Settings' start-listening button built the socket URL without the device token, so it could never arm a subscriber's service. tsc clean, 144 tests (15 new: dismissal memory, background arming, dead credential, unnamed ack queue).
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user