Two field reports, one chain: a dismissed alarm came back. Measured on the emulator, three separate causes, all fixed here. 1. The alarm-clock entry launched an activity. On Android 14+ that launch is refused from the background and then *deferred*, so the window surfaced later — after the alarm had been acknowledged — and the siren played again for a delivered alert. The entry is now a broadcast to AlarmWakeReceiver, which only wakes the process and re-posts the takeover notification, and ignores an entry that outlived its arming. The one activity PendingIntent left is the alarm-clock show intent, which only the user can invoke. 2. A dismissal could be lost. `acknowledgeAlarm` returned early when the device identity had not loaded, and the queue's entry required a deviceId, so the ack was silently dropped and the ledger still owed the alarm. The queue accepts an entry without one (measured: the service takes a device-token ack with no deviceId) and the flush resolves the identity. Nothing raises — and the armed service does not start listening — until the queued dismissals have been read, so a restart cannot ring before the seed. 3. Acknowledging now unsubscribes the device (owner ruling: a dismissal is leaving). The ack settles the ledger first, then the subscription row goes; the takeover closes itself and Home reports "Acknowledged — this device unsubscribed". A leftover subscription was a live address that kept ringing on every launch. Also: an alarm is only sounded for an alert this device still follows (or a trigger event that actually arrived), and the takeover never renders the raw alert id as its headline — that fallback is what made a stale launch look like an alarm for "dbf1a4c2-…". Verified on the emulator: join → trigger → acknowledge → subscription row gone, alert delivered, app returns Home by itself; force-stop and reopen is silent for the whole session (no audio, no notification, no takeover).
187 lines
7.7 KiB
TypeScript
187 lines
7.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|