import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Config, NotifyClass, PushPayload, PushSubscriptionRecord, Session, } from '../../src/types.js' import type { SubscriptionStore } from '../../src/push/subscription-store.js' import { createPushService, type PushSendOptions, type PushSender, } from '../../src/push/push-service.js' /* ── fixtures ─────────────────────────────────────────────────────── */ const BASE_CFG: Config = { port: 3000, bindHost: '0.0.0.0', shellPath: '/bin/zsh', homeDir: '/home/tester', idleTtlMs: 10_000, scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', maxSessions: 50, maxMsgsPerSec: 2000, permTimeoutMs: 300_000, reapIntervalMs: 60_000, previewBytes: 24 * 1024, useTmux: false, allowedOrigins: [], projectRoots: ['/home/tester'], projectScanDepth: 4, projectScanTtlMs: 10_000, projectDirtyCheck: true, editorCmd: 'code', vapidPublicKey: 'PUBKEY', vapidPrivateKey: 'PRIVKEY', vapidSubject: 'mailto:admin@example.com', pushStorePath: '/tmp/push-subs.json', pushMaxSubs: 50, notifyDone: true, notifyDnd: false, decisionTokenTtlMs: 300_000, timelineMax: 200, timelineEnabled: true, stuckTtlMs: 600_000, stuckAlert: true, diffTimeoutMs: 2000, diffMaxBytes: 2 * 1024 * 1024, diffMaxFiles: 300, statuslineTtlMs: 30_000, worktreeEnabled: true, worktreeRoot: undefined, worktreeTimeoutMs: 10_000, defaultPermissionMode: 'default', allowAutoMode: false, } function cfg(overrides: Partial = {}): Config { return { ...BASE_CFG, ...overrides } } const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' function fakeSession(id = SID, cwd: string | null = '/home/tester/my-repo'): Session { return { meta: { id, createdAt: 0, shellPath: '/bin/zsh' }, cwd } as unknown as Session } function rec(endpoint: string): PushSubscriptionRecord { return { endpoint, keys: { p256dh: 'p-' + endpoint, auth: 'a-' + endpoint }, createdAt: 1 } } interface SentCall { record: PushSubscriptionRecord payload: PushPayload options: PushSendOptions } /** A fake sender that records calls and can be told to throw per-endpoint. */ function makeSender(failures: Record = {}): PushSender & { sent: SentCall[] vapid: { subject: string; publicKey: string; privateKey: string } | null } { const sent: SentCall[] = [] let vapid: { subject: string; publicKey: string; privateKey: string } | null = null return { sent, get vapid() { return vapid }, setVapid(subject, publicKey, privateKey) { vapid = { subject, publicKey, privateKey } }, async send(record, payload, options) { const code = failures[record.endpoint] if (code) { const e = new Error('push failed') as Error & { statusCode: number } e.statusCode = code throw e } sent.push({ record, payload: JSON.parse(payload) as PushPayload, options }) }, } } /** A fake store backed by an in-memory array. */ function makeStore(initial: PushSubscriptionRecord[] = []): SubscriptionStore & { pruned: string[][] persistCount: number } { let records = [...initial] const pruned: string[][] = [] let persistCount = 0 return { pruned, get persistCount() { return persistCount }, list: () => Object.freeze([...records]), add(r) { records = [...records.filter((x) => x.endpoint !== r.endpoint), r] }, remove(endpoint) { records = records.filter((x) => x.endpoint !== endpoint) }, prune(dead) { pruned.push([...dead]) records = records.filter((x) => !dead.includes(x.endpoint)) }, async persist() { persistCount++ }, } } beforeEach(() => { vi.restoreAllMocks() }) /* ── isEnabled truth table ────────────────────────────────────────── */ describe('createPushService — isEnabled', () => { it('is true only when both VAPID keys are configured', () => { expect(createPushService(cfg(), makeStore(), { sender: makeSender() }).isEnabled()).toBe(true) expect( createPushService(cfg({ vapidPublicKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(), ).toBe(false) expect( createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender: makeSender() }).isEnabled(), ).toBe(false) expect( createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore(), { sender: makeSender(), }).isEnabled(), ).toBe(false) }) it('registers VAPID details with the sender when enabled', () => { const sender = makeSender() createPushService(cfg(), makeStore(), { sender }) expect(sender.vapid).toEqual({ subject: 'mailto:admin@example.com', publicKey: 'PUBKEY', privateKey: 'PRIVKEY', }) }) it('does not register VAPID details when disabled', () => { const sender = makeSender() createPushService(cfg({ vapidPrivateKey: undefined }), makeStore(), { sender }) expect(sender.vapid).toBeNull() }) it('falls back to a default subject when VAPID_SUBJECT is unset', () => { const sender = makeSender() createPushService(cfg({ vapidSubject: undefined }), makeStore(), { sender }) expect(sender.vapid?.subject).toMatch(/^mailto:/) }) }) /* ── notify: short-circuits ───────────────────────────────────────── */ describe('notify — short-circuits', () => { it('is a no-op when push is disabled', async () => { const sender = makeSender() const svc = createPushService(cfg({ vapidPublicKey: undefined }), makeStore([rec('e1')]), { sender }) await svc.notify(fakeSession(), 'needs-input', 'tok') expect(sender.sent).toHaveLength(0) }) it('is a no-op when NOTIFY_DND is on', async () => { const sender = makeSender() const svc = createPushService(cfg({ notifyDnd: true }), makeStore([rec('e1')]), { sender }) await svc.notify(fakeSession(), 'needs-input', 'tok') expect(sender.sent).toHaveLength(0) }) it('drops DONE notifications when NOTIFY_DONE is off, but still sends needs-input/stuck', async () => { const sender = makeSender() const svc = createPushService(cfg({ notifyDone: false }), makeStore([rec('e1')]), { sender }) await svc.notify(fakeSession(), 'done') expect(sender.sent).toHaveLength(0) await svc.notify(fakeSession(), 'needs-input', 'tok') await svc.notify(fakeSession(), 'stuck') expect(sender.sent.map((c) => c.payload.cls)).toEqual(['needs-input', 'stuck']) }) it('is a no-op when there are no subscriptions', async () => { const sender = makeSender() const svc = createPushService(cfg(), makeStore([]), { sender }) await svc.notify(fakeSession(), 'needs-input', 'tok') expect(sender.sent).toHaveLength(0) }) }) /* ── notify: payload shape ────────────────────────────────────────── */ describe('notify — payload (§3.3 PushPayload)', () => { it('sends a minimal needs-input payload with the capability token to every subscription', async () => { const sender = makeSender() const svc = createPushService(cfg(), makeStore([rec('e1'), rec('e2')]), { sender }) await svc.notify(fakeSession(), 'needs-input', 'cap-tok') expect(sender.sent).toHaveLength(2) const { payload } = sender.sent[0]! expect(payload.sessionId).toBe(SID) expect(payload.cls).toBe('needs-input') expect(payload.token).toBe('cap-tok') // minimal: only known keys, no raw output / secrets expect(Object.keys(payload).sort()).toEqual(['cls', 'detail', 'sessionId', 'token']) expect(payload.detail).toBe('my-repo') }) it('omits the token for non-needs-input classes even when one is passed', async () => { const sender = makeSender() const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) await svc.notify(fakeSession(), 'done', 'leaked-token') expect(sender.sent[0]!.payload.token).toBeUndefined() }) it('omits detail when the session has no cwd', async () => { const sender = makeSender() const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) await svc.notify(fakeSession(SID, null), 'stuck') expect(sender.sent[0]!.payload.detail).toBeUndefined() }) }) /* ── notify: web-push options ─────────────────────────────────────── */ describe('notify — send options', () => { it('uses high urgency for needs-input/stuck and low for done', async () => { const sender = makeSender() const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) await svc.notify(fakeSession(), 'needs-input', 'tok') await svc.notify(fakeSession(), 'stuck') await svc.notify(fakeSession(), 'done') expect(sender.sent.map((c) => c.options.urgency)).toEqual(['high', 'high', 'low']) }) it('sets a per-session collapse topic (valid Topic header, no dashes, <=32 chars)', async () => { const sender = makeSender() const svc = createPushService(cfg(), makeStore([rec('e1')]), { sender }) await svc.notify(fakeSession(), 'needs-input', 'tok') const topic = sender.sent[0]!.options.topic! expect(topic).toBe(SID.replace(/-/g, '')) expect(topic.length).toBeLessThanOrEqual(32) expect(topic).toMatch(/^[A-Za-z0-9_-]+$/) }) }) /* ── notify: pruning dead subscriptions ───────────────────────────── */ describe('notify — pruning', () => { for (const code of [404, 410] as const) { it(`prunes + persists a subscription that returns ${code}`, async () => { const sender = makeSender({ e2: code }) const store = makeStore([rec('e1'), rec('e2'), rec('e3')]) const svc = createPushService(cfg(), store, { sender }) await svc.notify(fakeSession(), 'needs-input', 'tok') expect(store.pruned).toEqual([['e2']]) expect(store.persistCount).toBe(1) // the live subscriptions still received the push expect(sender.sent.map((c) => c.record.endpoint).sort()).toEqual(['e1', 'e3']) }) } it('does NOT prune on transient errors (e.g. 500); logs and keeps the subscription', async () => { const err = vi.spyOn(console, 'error').mockImplementation(() => {}) const sender = makeSender({ e1: 500 }) const store = makeStore([rec('e1'), rec('e2')]) const svc = createPushService(cfg(), store, { sender }) await svc.notify(fakeSession(), 'done') expect(store.pruned).toEqual([]) expect(store.persistCount).toBe(0) expect(err).toHaveBeenCalled() expect(sender.sent.map((c) => c.record.endpoint)).toEqual(['e2']) }) it('does not persist when no subscriptions are dead', async () => { const sender = makeSender() const store = makeStore([rec('e1')]) const svc = createPushService(cfg(), store, { sender }) await svc.notify(fakeSession(), 'needs-input', 'tok') expect(store.persistCount).toBe(0) }) }) /* ── default sender (web-push) is wired without crashing ──────────── */ describe('createPushService — default sender', () => { it('constructs with the real web-push sender when none is injected', () => { // disabled config → setVapidDetails not called, so no real keys needed const svc = createPushService(cfg({ vapidPublicKey: undefined, vapidPrivateKey: undefined }), makeStore()) expect(svc.isEnabled()).toBe(false) }) }) /* exhaustiveness guard: every NotifyClass is exercised above */ const _classes: NotifyClass[] = ['needs-input', 'done', 'stuck'] void _classes