Skip to content

Commit b44b2cf

Browse files
fix: harden push notification delivery
1 parent d85d730 commit b44b2cf

2 files changed

Lines changed: 162 additions & 5 deletions

File tree

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,122 @@
11
import { NotificationsService } from './notifications.service';
22

3+
jest.mock('ioredis', () =>
4+
jest.fn().mockImplementation(() => ({
5+
publish: jest.fn().mockResolvedValue(undefined),
6+
quit: jest.fn().mockResolvedValue(undefined),
7+
subscribe: jest.fn().mockResolvedValue(undefined),
8+
on: jest.fn(),
9+
})),
10+
);
11+
312
describe('NotificationsService', () => {
4-
it('should be defined', () => {
5-
expect(NotificationsService).toBeDefined();
13+
function createService() {
14+
const prisma: any = {
15+
pushToken: {
16+
findMany: jest.fn(),
17+
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
18+
},
19+
};
20+
const jobsQueueService: any = {
21+
enqueueNotification: jest.fn().mockResolvedValue(undefined),
22+
};
23+
const config: any = {
24+
redisUrl: 'redis://localhost:6379',
25+
};
26+
const service = new NotificationsService(config, prisma, jobsQueueService);
27+
28+
(service as any).publisher = {
29+
publish: jest.fn().mockResolvedValue(undefined),
30+
quit: jest.fn().mockResolvedValue(undefined),
31+
};
32+
(service as any).subscriber = {
33+
subscribe: jest.fn().mockResolvedValue(undefined),
34+
on: jest.fn(),
35+
quit: jest.fn().mockResolvedValue(undefined),
36+
};
37+
(service as any).expo = {
38+
chunkPushNotifications: jest.fn((messages) => [messages]),
39+
sendPushNotificationsAsync: jest.fn(),
40+
};
41+
42+
return { service, prisma, jobsQueueService };
43+
}
44+
45+
it('queues a notification event and background job', async () => {
46+
const { service, jobsQueueService } = createService();
47+
48+
await service.sendPushNotification(['user-1'], {
49+
type: 'expense_created',
50+
title: 'Expense added',
51+
body: 'Dinner was added',
52+
});
53+
54+
expect(jobsQueueService.enqueueNotification).toHaveBeenCalledWith({
55+
userIds: ['user-1'],
56+
payload: {
57+
type: 'expense_created',
58+
title: 'Expense added',
59+
body: 'Dinner was added',
60+
},
61+
});
62+
});
63+
64+
it('removes invalid Expo tokens after a push send response', async () => {
65+
const { service, prisma } = createService();
66+
prisma.pushToken.findMany.mockResolvedValue([
67+
{ token: 'ExponentPushToken[validToken]' },
68+
{ token: 'ExponentPushToken[invalidToken]' },
69+
]);
70+
(service as any).expo.sendPushNotificationsAsync.mockResolvedValue([
71+
{ status: 'ok', id: 'ticket-1' },
72+
{ status: 'error', details: { error: 'DeviceNotRegistered' } },
73+
]);
74+
75+
await service.processQueuedNotification({
76+
userIds: ['user-1'],
77+
payload: {
78+
type: 'group_invite',
79+
title: 'Invite',
80+
body: 'You were invited',
81+
},
82+
});
83+
84+
expect(prisma.pushToken.deleteMany).toHaveBeenCalledWith({
85+
where: {
86+
token: {
87+
in: ['ExponentPushToken[invalidToken]'],
88+
},
89+
},
90+
});
91+
});
92+
93+
it('retries Expo delivery with exponential backoff for transient failures', async () => {
94+
const { service, prisma } = createService();
95+
prisma.pushToken.findMany.mockResolvedValue([{ token: 'ExponentPushToken[retryToken]' }]);
96+
const sendPushNotificationsAsync = (service as any).expo.sendPushNotificationsAsync as jest.Mock;
97+
sendPushNotificationsAsync
98+
.mockRejectedValueOnce(new Error('transient failure'))
99+
.mockResolvedValueOnce([{ status: 'ok', id: 'ticket-1' }]);
100+
101+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback: TimerHandler) => {
102+
if (typeof callback === 'function') {
103+
callback();
104+
}
105+
return 0 as unknown as ReturnType<typeof setTimeout>;
106+
});
107+
108+
await service.processQueuedNotification({
109+
userIds: ['user-1'],
110+
payload: {
111+
type: 'settlement_created',
112+
title: 'Settlement',
113+
body: 'A settlement was recorded',
114+
},
115+
});
116+
117+
expect(sendPushNotificationsAsync).toHaveBeenCalledTimes(2);
118+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
119+
120+
setTimeoutSpy.mockRestore();
6121
});
7122
});

apps/backend/src/notifications/notifications.service.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { forwardRef, Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
2-
import { Expo, ExpoPushMessage } from 'expo-server-sdk';
2+
import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk';
33
import Redis from 'ioredis';
44
import { AppConfigService } from '../config/app-config.service';
55
import { PrismaService } from '../common/prisma.service';
@@ -16,6 +16,8 @@ type NotificationEventPayload = {
1616

1717
@Injectable()
1818
export class NotificationsService implements OnModuleInit, OnModuleDestroy {
19+
private static readonly maxChunkRetries = 3;
20+
private static readonly baseRetryDelayMs = 500;
1921
private readonly logger = new Logger(NotificationsService.name);
2022
private readonly channel = 'fairshare:notifications';
2123
private readonly publisher: Redis;
@@ -110,13 +112,14 @@ export class NotificationsService implements OnModuleInit, OnModuleDestroy {
110112

111113
const chunks = this.expo.chunkPushNotifications(messages);
112114
for (const chunk of chunks) {
113-
await this.sendChunkWithRetry(chunk, 1);
115+
await this.sendChunkWithRetry(chunk, NotificationsService.maxChunkRetries);
114116
}
115117
}
116118

117119
private async sendChunkWithRetry(chunk: ExpoPushMessage[], retries: number): Promise<void> {
118120
try {
119121
const tickets = await this.expo.sendPushNotificationsAsync(chunk);
122+
await this.removeInvalidTokens(chunk, tickets);
120123
this.logger.log(
121124
JSON.stringify({
122125
event: 'notification_push_sent',
@@ -126,9 +129,48 @@ export class NotificationsService implements OnModuleInit, OnModuleDestroy {
126129
} catch (error) {
127130
this.logger.error('Expo push send failed', error instanceof Error ? error.stack : undefined);
128131
if (retries > 0) {
129-
await new Promise((resolve) => setTimeout(resolve, 500));
132+
const attempt = NotificationsService.maxChunkRetries - retries + 1;
133+
const delayMs = NotificationsService.baseRetryDelayMs * (2 ** (attempt - 1));
134+
await new Promise((resolve) => setTimeout(resolve, delayMs));
130135
await this.sendChunkWithRetry(chunk, retries - 1);
131136
}
132137
}
133138
}
139+
140+
private async removeInvalidTokens(chunk: ExpoPushMessage[], tickets: ExpoPushTicket[]): Promise<void> {
141+
const invalidTokens = tickets
142+
.map((ticket, index) => {
143+
if (ticket.status !== 'error') {
144+
return null;
145+
}
146+
147+
const errorCode = ticket.details && 'error' in ticket.details ? ticket.details.error : undefined;
148+
if (errorCode !== 'DeviceNotRegistered') {
149+
return null;
150+
}
151+
152+
const recipient = chunk[index]?.to;
153+
return typeof recipient === 'string' ? recipient : null;
154+
})
155+
.filter((token): token is string => Boolean(token));
156+
157+
if (invalidTokens.length === 0) {
158+
return;
159+
}
160+
161+
await this.prisma.pushToken.deleteMany({
162+
where: {
163+
token: {
164+
in: invalidTokens,
165+
},
166+
},
167+
});
168+
169+
this.logger.warn(
170+
JSON.stringify({
171+
event: 'notification_invalid_tokens_removed',
172+
count: invalidTokens.length,
173+
}),
174+
);
175+
}
134176
}

0 commit comments

Comments
 (0)