Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 1e1814def0 refactor(push): name notification sender dependencies
Use an options object for the logger, token revocation callback, and HTTP transport while retaining native fetch by default.
2026-11-02 00:06:18 +00:00
paseo-bot[bot]andAndrii Panasiuk 2d864df02e test(push): cover urgent notifications without global stubs
Keep the push transport explicit so the priority regression exercises every batch without replacing global fetch. Production callers retain the native transport by default.

Co-authored-by: Andrii Panasiuk <andrew.panassiouk@gmail.com>
2026-11-02 00:00:57 +00:00
Andrii Panasiuk 57586e6b52 fix(server): send push notifications at high priority so Android delivers them on time 2026-11-01 23:59:57 +00:00
3 changed files with 60 additions and 5 deletions
+4 -1
View File
@@ -23,7 +23,10 @@ export function createPushNotifications(options: {
}): PushNotifications {
const now = options.now ?? Date.now;
const store = new PushTokenStore(options.logger, options.filePath, now, PUSH_TOKEN_LEASE_MS);
const service = new PushService(options.logger, (token) => store.revokeToken(token));
const service = new PushService({
logger: options.logger,
revokeToken: (token) => store.revokeToken(token),
});
const deliver =
options.deliver ??
((tokens: string[], payload: PushPayload) => service.sendPush(tokens, payload));
@@ -0,0 +1,41 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { PushService } from "./push-service.js";
describe("PushService", () => {
test("requests high priority for every notification across batches", async () => {
const requests: Record<string, unknown>[][] = [];
const sendRequest: typeof fetch = async (_url, init) => {
const messages = JSON.parse(String(init?.body)) as Record<string, unknown>[];
requests.push(messages);
return new Response(
JSON.stringify({ data: messages.map(() => ({ status: "ok", id: "ticket" })) }),
);
};
const service = new PushService({
logger: pino({ level: "silent" }),
revokeToken: () => undefined,
sendRequest,
});
const tokens = Array.from({ length: 101 }, (_, index) => `ExponentPushToken[device-${index}]`);
await service.sendPush(tokens, {
title: "Agent finished",
body: "Done",
data: { agentId: "agent" },
});
expect(requests.map((batch) => batch.length)).toEqual([100, 1]);
expect(requests.flat()).toEqual(
tokens.map((token) => ({
to: token,
priority: "high",
title: "Agent finished",
body: "Done",
data: { agentId: "agent" },
sound: "default",
})),
);
});
});
@@ -12,6 +12,7 @@ interface ExpoPushMessage {
body: string;
data?: Record<string, unknown>;
sound?: "default";
priority?: "default" | "normal" | "high";
}
interface ExpoPushTicket {
@@ -31,10 +32,16 @@ const MAX_BATCH_SIZE = 100;
export class PushService {
private readonly logger: pino.Logger;
private readonly revokeToken: (token: string) => void;
private readonly sendRequest: typeof fetch;
constructor(logger: pino.Logger, revokeToken: (token: string) => void) {
this.logger = logger.child({ component: "push-service" });
this.revokeToken = revokeToken;
constructor(options: {
logger: pino.Logger;
revokeToken: (token: string) => void;
sendRequest?: typeof fetch;
}) {
this.logger = options.logger.child({ component: "push-service" });
this.revokeToken = options.revokeToken;
this.sendRequest = options.sendRequest ?? fetch;
}
async sendPush(tokens: string[], payload: PushPayload): Promise<void> {
@@ -42,12 +49,16 @@ export class PushService {
return;
}
// Expo defaults to "normal" priority on Android, which FCM holds back while
// the device dozes, so notifications arrive minutes late. iOS already
// defaults to "high".
const messages: ExpoPushMessage[] = tokens.map((token) => ({
to: token,
title: payload.title,
body: payload.body,
data: payload.data,
sound: "default",
priority: "high",
}));
// Batch tokens (max 100 per request per Expo limits)
@@ -61,7 +72,7 @@ export class PushService {
private async sendBatch(messages: ExpoPushMessage[]): Promise<void> {
try {
const response = await fetch(EXPO_PUSH_URL, {
const response = await this.sendRequest(EXPO_PUSH_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",