fix(api): correct apns clear payload and badge handling (#2092)

This commit is contained in:
Hampus
2026-08-29 20:07:18 +02:00
committed by GitHub
parent 8db4f5cb63
commit cb889b1160
2 changed files with 48 additions and 16 deletions
+9 -14
View File
@@ -143,7 +143,7 @@ function buildApnsHeaders(params: {
'apns-topic': params.topic,
'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT,
'apns-priority': isClear ? '5' : '10',
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 300 : 86400)),
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 3600 : 86400)),
'content-type': 'application/json',
};
if (collapseId && Buffer.byteLength(collapseId) <= 64) {
@@ -155,18 +155,11 @@ function buildApnsHeaders(params: {
function buildApnsPayload(payload: Record<string, unknown>): Record<string, unknown> {
if (isClearNotificationPayload(payload)) {
const data = isRecord(payload.data) ? payload.data : {};
const badge = normalizeBadgeCount(data.badge_count);
const aps: Record<string, unknown> = {
'content-available': 1,
};
if (badge !== undefined) {
aps.badge = badge;
}
return {
...data,
type: 'notification_clear',
action: 'clear_channel',
aps,
aps: {'content-available': 1},
};
}
const data = isRecord(payload.data) ? payload.data : {};
@@ -177,16 +170,18 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
const channelId = optionalString(data.channel_id);
const threadId =
optionalString(data.notification_tag) ?? (channelId ? `channel:${channelId}` : undefined) ?? 'fluxer-message';
const imageUrl = firstString([payload.image_url, notification.image, notification.icon, payload.icon]);
const imageUrl = firstString([payload.image_url, notification.image]);
const aps: Record<string, unknown> = {
alert: {title, body},
sound: APNS_DEFAULT_SOUND,
badge,
'thread-id': threadId,
category: APNS_CATEGORY_MESSAGE,
'interruption-level': 'active',
'relevance-score': 0.5,
};
if (badge !== undefined) {
aps.badge = badge;
}
if (imageUrl) {
aps['mutable-content'] = 1;
}
@@ -292,13 +287,13 @@ function isPermanentApnsFailure(statusCode: number, reason: string): boolean {
return reason === 'Unregistered';
}
function normalizeBadgeCount(value: unknown): number {
function normalizeBadgeCount(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
return Number.isFinite(parsed) ? Math.max(0, parsed) : undefined;
}
return 0;
return undefined;
}
function firstString(values: Array<unknown>): string | undefined {
@@ -38,7 +38,7 @@ describe('ApnsPushService', () => {
},
});
});
it('builds silent APNs clear payloads with badge when badge_count is present', () => {
it('builds silent APNs clear payloads as pure background pushes', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
type: 'notification_clear',
action: 'clear_channel',
@@ -56,11 +56,11 @@ describe('ApnsPushService', () => {
badge_count: 0,
aps: {
'content-available': 1,
badge: 0,
},
});
expect(payload.aps).not.toHaveProperty('alert');
expect(payload.aps).not.toHaveProperty('sound');
expect(payload.aps).not.toHaveProperty('badge');
});
it('uses APNs push-type and priority headers that match alert versus background delivery', () => {
const alertHeaders = ApnsPushServiceTestHooks.buildApnsHeaders({
@@ -94,6 +94,43 @@ describe('ApnsPushService', () => {
'apns-priority': '5',
'apns-collapse-id': 'channel:123',
});
const now = Math.floor(Date.now() / 1000);
expect(Number(clearHeaders['apns-expiration']) - now).toBeGreaterThan(1800);
});
it('omits the badge entirely when no usable badge count is supplied', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456'},
notification: {title: 'Alice', body: 'Hello'},
});
expect(payload.aps).not.toHaveProperty('badge');
const unparseable = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', badge_count: 'not-a-number'},
notification: {title: 'Alice', body: 'Hello'},
});
expect(unparseable.aps).not.toHaveProperty('badge');
});
it('keeps a genuine zero badge so reading the last message clears the app icon', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', badge_count: 0},
notification: {title: 'Alice', body: 'Hello'},
});
expect((payload.aps as Record<string, unknown>).badge).toBe(0);
});
it('does not use the sender avatar as the notification media image', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', author_avatar_url: 'https://cdn.example/avatar.png'},
notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'},
});
expect(payload.image_url).toBeUndefined();
expect(payload.aps).not.toHaveProperty('mutable-content');
expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png');
});
it('marks only permanent APNs token failures as subscription deletion signals', () => {
expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true);