import { describe, expect, it, vi } from 'vitest'; import { PushService, type PushSender, type PushPayload } from '../src/core/push-service.js'; import { openDb, getSetting, type Db } from '../src/db/index.js'; function sub(endpoint: string): { endpoint: string; keys: { p256dh: string; auth: string } } { return { endpoint, keys: { p256dh: `p-${endpoint}`, auth: `a-${endpoint}` } }; } const payload: PushPayload = { sessionId: 's1', title: 't', body: 'b', kind: 'permission', url: '/sessions/s1' }; describe('PushService', () => { it('génère les clés VAPID une seule fois (idempotent) et les persiste', () => { const db: Db = openDb(':memory:'); const a = new PushService(db); const pub = a.publicKey(); expect(pub).toMatch(/.{20,}/); // clé base64url non triviale expect(getSetting(db, 'vapid_private')).not.toBeNull(); // une seconde instance sur la même db réutilise les clés (pas de régénération) const b = new PushService(db); expect(b.publicKey()).toBe(pub); }); it('subscribe (UPSERT par endpoint), count et unsubscribe', () => { const db = openDb(':memory:'); const p = new PushService(db); p.subscribe('tok1', sub('https://push/a'), 'UA'); p.subscribe('tok1', sub('https://push/b'), null); expect(p.count()).toBe(2); // ré-abonnement du même endpoint → pas de doublon p.subscribe('tok2', sub('https://push/a'), 'UA2'); expect(p.count()).toBe(2); p.unsubscribe('https://push/a'); expect(p.count()).toBe(1); }); it('notify : supprime l’abonnement sur 410 Gone, conserve sur autre erreur, marque last_ok_at sur succès', async () => { const db = openDb(':memory:'); const sender: PushSender = vi.fn(async (s) => { if (s.endpoint.endsWith('/gone')) throw Object.assign(new Error('gone'), { statusCode: 410 }); if (s.endpoint.endsWith('/flaky')) throw Object.assign(new Error('boom'), { statusCode: 500 }); return { statusCode: 201 }; }); const p = new PushService(db, 'mailto:test@x', sender); p.subscribe('tok', sub('https://push/ok'), null); p.subscribe('tok', sub('https://push/gone'), null); p.subscribe('tok', sub('https://push/flaky'), null); await p.notify(payload); expect(sender).toHaveBeenCalledTimes(3); // 410 → purgé ; 500 → conservé ; ok → conservé expect(p.count()).toBe(2); const rows = db.prepare('SELECT endpoint, last_ok_at FROM push_subscriptions ORDER BY endpoint').all() as Array<{ endpoint: string; last_ok_at: string | null }>; const byEndpoint = Object.fromEntries(rows.map((r) => [r.endpoint, r.last_ok_at])); expect(byEndpoint['https://push/ok']).not.toBeNull(); // succès horodaté expect(byEndpoint['https://push/flaky']).toBeNull(); // échec transitoire : pas d'horodatage, mais conservé expect(byEndpoint['https://push/gone']).toBeUndefined(); }); it('notify sans abonnement : ne touche pas au sender', async () => { const db = openDb(':memory:'); const sender: PushSender = vi.fn(async () => ({})); const p = new PushService(db, 'mailto:test@x', sender); await p.notify(payload); expect(sender).not.toHaveBeenCalled(); }); it('le payload poussé est le JSON sérialisé de PushPayload', async () => { const db = openDb(':memory:'); let captured = ''; const sender: PushSender = async (_s, body) => { captured = body; return {}; }; const p = new PushService(db, 'mailto:test@x', sender); p.subscribe('tok', sub('https://push/ok'), null); await p.notify(payload); expect(JSON.parse(captured)).toEqual(payload); }); });