From 880a3097af5dd013f3e01397a7fc588d5271ff0a Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 20:58:18 +0200 Subject: [PATCH] ST-cmovs7jgr: lib/push-server.ts met sendPushToUser + stale-cleanup Server-only push-lib met VAPID feature-gate, send naar alle subscriptions van een user, en automatische cleanup bij 404/410. Unit tests: success-pad, 410 verwijdert sub, 404 verwijdert sub, andere errors loggen zonder delete. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/lib/push-server.test.ts | 79 +++++++++++++++++++++++++++++++ lib/push-server.ts | 64 +++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 __tests__/lib/push-server.test.ts create mode 100644 lib/push-server.ts diff --git a/__tests__/lib/push-server.test.ts b/__tests__/lib/push-server.test.ts new file mode 100644 index 0000000..7a9b75a --- /dev/null +++ b/__tests__/lib/push-server.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('server-only', () => ({})) + +const { mockSendNotification } = vi.hoisted(() => ({ + mockSendNotification: vi.fn(), +})) + +vi.mock('web-push', () => ({ + default: { + setVapidDetails: vi.fn(), + sendNotification: mockSendNotification, + }, +})) + +vi.mock('@/lib/env', () => ({ + env: { + NEXT_PUBLIC_VAPID_PUBLIC_KEY: 'pk', + VAPID_PRIVATE_KEY: 'sk', + VAPID_SUBJECT: 'mailto:test@example.com', + }, +})) + +const { mockPushSubscription } = vi.hoisted(() => ({ + mockPushSubscription: { + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { pushSubscription: mockPushSubscription }, +})) + +import { sendPushToUser } from '@/lib/push-server' + +const SUB = { id: 'sub-1', endpoint: 'https://push.example.com/1', p256dh: 'p256dh', auth: 'auth' } +const PAYLOAD = { title: 'Test', body: 'Body', url: '/test' } + +beforeEach(() => { + vi.clearAllMocks() + mockPushSubscription.findMany.mockResolvedValue([SUB]) + mockPushSubscription.update.mockResolvedValue(SUB) + mockPushSubscription.delete.mockResolvedValue(SUB) +}) + +describe('sendPushToUser', () => { + it('sends notification and updates last_used_at on success', async () => { + mockSendNotification.mockResolvedValue({ statusCode: 201 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockSendNotification).toHaveBeenCalledOnce() + expect(mockPushSubscription.update).toHaveBeenCalledWith({ + where: { id: SUB.id }, + data: { last_used_at: expect.any(Date) }, + }) + }) + + it('deletes subscription on 410 (expired)', async () => { + mockSendNotification.mockRejectedValue({ statusCode: 410 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } }) + expect(mockPushSubscription.update).not.toHaveBeenCalled() + }) + + it('deletes subscription on 404 (not found)', async () => { + mockSendNotification.mockRejectedValue({ statusCode: 404 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } }) + }) + + it('logs error but does not delete on other error status', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + mockSendNotification.mockRejectedValue({ statusCode: 500 }) + await sendPushToUser('user-1', PAYLOAD) + expect(mockPushSubscription.delete).not.toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) +}) diff --git a/lib/push-server.ts b/lib/push-server.ts new file mode 100644 index 0000000..5e1ef59 --- /dev/null +++ b/lib/push-server.ts @@ -0,0 +1,64 @@ +import 'server-only' + +import webpush from 'web-push' +import { prisma } from '@/lib/prisma' +import { env } from '@/lib/env' + +export type PushPayload = { + title: string + body: string + url: string + tag?: string +} + +const vapidReady = + !!env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && + !!env.VAPID_PRIVATE_KEY && + !!env.VAPID_SUBJECT + +if (vapidReady) { + webpush.setVapidDetails( + env.VAPID_SUBJECT!, + env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, + env.VAPID_PRIVATE_KEY!, + ) +} + +export const enabled = vapidReady + +export async function sendPushToUser(userId: string, payload: PushPayload): Promise { + if (!enabled) { + console.warn('[push-server] VAPID not configured — skipping push for user', userId) + return + } + + const subs = await prisma.pushSubscription.findMany({ where: { user_id: userId } }) + await Promise.allSettled(subs.map((sub) => sendOne(sub, payload))) +} + +async function sendOne( + sub: { id: string; endpoint: string; p256dh: string; auth: string }, + payload: PushPayload, +): Promise { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload), + ) + await prisma.pushSubscription.update({ + where: { id: sub.id }, + data: { last_used_at: new Date() }, + }) + } catch (err: unknown) { + const status = (err as { statusCode?: number }).statusCode + if (status === 404 || status === 410) { + try { + await prisma.pushSubscription.delete({ where: { id: sub.id } }) + } catch { + // already deleted by a concurrent request — ignore + } + } else { + console.error('[push-server] sendNotification error for endpoint', sub.endpoint, err) + } + } +}