- 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)
80 lines
3.5 KiB
TypeScript
80 lines
3.5 KiB
TypeScript
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);
|
||
});
|
||
});
|