/** * The silent push that tells a device its pass changed. * * It carries nothing. There is no message, no badge and no sound — the payload * is an empty object, and everything the holder eventually sees comes from the * pass the device then fetches. Which is why a campaign in a loyalty product is * a *field change* rather than a notification: this is the only channel, and it * says nothing on its own. */ export interface ApnsPush { /** The token the device handed over when it registered. */ pushToken: string; /** * The pass type identifier, which is what APNs calls the topic for a pass. * * Not the application's bundle identifier, which is the mistake that produces * a `TopicDisallowed` for every push and no other symptom. */ topic: string; /** Deduplicates retries at APNs. One per pass update, reused across retries. */ collapseId?: string; } export interface ApnsResult { pushToken: string; /** 200 accepted. 410 the device no longer has the pass. 400-ish, our mistake. */ status: number; /** APNs' own word for what was wrong, e.g. `Unregistered`, `BadDeviceToken`. */ reason?: string; /** APNs' identifier for the push, for correlating with their support. */ apnsId?: string; } /** * How a push is sent, behind an interface. * * The same shape as the transport ports in `comms` and `messaging`, and for the * same reason: a local run and every test must be able to prove the *request* * without a network, a certificate or an Apple account. The fake below records; * the client beside it sends. */ export interface ApnsPort { send(pushes: readonly ApnsPush[]): Promise; } /** * Records what would have been sent, and accepts all of it. * * Used by every test and by the local stack. It is the reason coalescing can be * asserted by *counting* — five changes to one pass inside the window must * produce one entry here, and reading the code instead proves nothing. */ export class RecordingApns implements ApnsPort { readonly sent: ApnsPush[] = []; /** Statuses to answer with, by push token. Anything unlisted is accepted. */ private readonly answers = new Map(); send(pushes: readonly ApnsPush[]): Promise { this.sent.push(...pushes); return Promise.resolve( pushes.map((push) => { const answer = this.answers.get(push.pushToken) ?? { status: 200 }; return { pushToken: push.pushToken, status: answer.status, ...(answer.reason ? { reason: answer.reason } : {}), }; }), ); } /** * Makes one token answer the way a device that removed the pass does. * * `410 Unregistered` is the only signal there is that a holder deleted their * card without the deregistration reaching us, and a product that keeps * pushing to it is a product whose delivery numbers are quietly fiction. */ unregister(pushToken: string): void { this.answers.set(pushToken, { status: 410, reason: 'Unregistered' }); } refuse(pushToken: string, status: number, reason: string): void { this.answers.set(pushToken, { status, reason }); } clear(): void { this.sent.length = 0; this.answers.clear(); } } /** * Coalesces pushes so one pass produces one notification. * * Five stamps added to five different customers is five pushes; five changes to * one pass within a few seconds is one. Without this a busy counter sends a * device a push per scan and the holder's phone buzzes its way through a * queue — which is the sort of thing that gets a pass deleted. * * Keyed on token *and* topic, because one device holds several passes and only * the pair identifies which. */ export function coalesce(pushes: readonly ApnsPush[]): ApnsPush[] { const seen = new Map(); for (const push of pushes) { /* Last wins, so a collapse identifier from the most recent change survives. */ seen.set(`${push.topic}${push.pushToken}`, push); } return [...seen.values()]; }