Files
arboretum/packages/server/test/push-service.test.ts
Johan LEROY 664e2b583c P4-B: notifications Web Push (VAPID) sur passage en waiting
- db: migration id=4 `push_subscriptions` (liées au token d'auth) ; clés VAPID en settings
- PushService: bootstrap idempotent des clés VAPID, subscribe/unsubscribe/count,
  notify() best-effort (purge des abonnements 410/404 Gone) ; sender injectable pour les tests
- routes/push.ts: GET vapid-public-key, POST subscribe/unsubscribe/test (toutes sous auth globale)
- pty-manager: déclencheur push sur FRONT MONTANT vers waiting, débouncé 1500ms et annulable
  (faux positif ignoré) ; câblage app/index/config (--vapid-contact)
- shared/api.ts: types VapidKeyResponse / PushSubscribeRequest / PushUnsubscribeRequest
- deps: web-push (+ @types/web-push) côté serveur
- tests: push-service (idempotence, UPSERT, 410-purge, payload) + trigger pty-manager (169 verts)
2026-06-15 12:13:56 +02:00

80 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 labonnement 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);
});
});