import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { PushSubscriptionRecord } from '../../src/types.js' import { isValidSubscriptionRecord, loadSubscriptionStore, } from '../../src/push/subscription-store.js' const dirs: string[] = [] function tmpFile(name = 'push-subs.json'): string { const dir = mkdtempSync(join(tmpdir(), 'webterm-subs-')) dirs.push(dir) return join(dir, name) } function rec(endpoint: string, createdAt = 1000): PushSubscriptionRecord { return { endpoint, keys: { p256dh: 'pub-' + endpoint, auth: 'auth-' + endpoint }, createdAt } } afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) vi.restoreAllMocks() }) describe('isValidSubscriptionRecord', () => { it('accepts a well-formed record', () => { expect(isValidSubscriptionRecord(rec('https://push/1'))).toBe(true) }) it('rejects non-objects and missing/invalid fields', () => { expect(isValidSubscriptionRecord(null)).toBe(false) expect(isValidSubscriptionRecord('x')).toBe(false) expect(isValidSubscriptionRecord({})).toBe(false) expect(isValidSubscriptionRecord({ endpoint: '', keys: { p256dh: 'a', auth: 'b' }, createdAt: 1 })).toBe(false) expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a' }, createdAt: 1 })).toBe(false) expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 1, auth: 'b' }, createdAt: 1 })).toBe(false) expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a', auth: 'b' } })).toBe(false) expect(isValidSubscriptionRecord({ endpoint: 'e', keys: { p256dh: 'a', auth: 'b' }, createdAt: NaN })).toBe(false) }) }) describe('loadSubscriptionStore — loading', () => { it('starts empty when the file is missing', () => { const store = loadSubscriptionStore(tmpFile(), 50) expect(store.list()).toEqual([]) }) it('starts empty (and logs) on malformed JSON', () => { const err = vi.spyOn(console, 'error').mockImplementation(() => {}) const path = tmpFile() writeFileSync(path, '{ not valid json') const store = loadSubscriptionStore(path, 50) expect(store.list()).toEqual([]) expect(err).toHaveBeenCalled() }) it('filters out invalid records on load', () => { const path = tmpFile() writeFileSync( path, JSON.stringify([rec('https://push/ok'), { endpoint: 123 }, { foo: 'bar' }]), ) const store = loadSubscriptionStore(path, 50) expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/ok']) }) it('returns empty when the JSON is not an array', () => { const path = tmpFile() writeFileSync(path, JSON.stringify({ endpoint: 'x' })) expect(loadSubscriptionStore(path, 50).list()).toEqual([]) }) }) describe('SubscriptionStore — list immutability', () => { it('returns a frozen copy that cannot be mutated', () => { const store = loadSubscriptionStore(tmpFile(), 50) store.add(rec('https://push/1')) const snapshot = store.list() expect(Object.isFrozen(snapshot)).toBe(true) expect(() => (snapshot as PushSubscriptionRecord[]).push(rec('https://push/2'))).toThrow() // original store is unaffected expect(store.list()).toHaveLength(1) }) }) describe('SubscriptionStore — add', () => { it('appends valid records', () => { const store = loadSubscriptionStore(tmpFile(), 50) store.add(rec('https://push/1')) store.add(rec('https://push/2')) expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/1', 'https://push/2']) }) it('throws on an invalid record (fail-fast at the boundary)', () => { const store = loadSubscriptionStore(tmpFile(), 50) expect(() => store.add({ endpoint: '' } as unknown as PushSubscriptionRecord)).toThrow() }) it('deduplicates by endpoint, replacing the prior record', () => { const store = loadSubscriptionStore(tmpFile(), 50) store.add(rec('https://push/1', 1000)) store.add({ endpoint: 'https://push/1', keys: { p256dh: 'new', auth: 'new' }, createdAt: 2000 }) const list = store.list() expect(list).toHaveLength(1) expect(list[0]?.keys.p256dh).toBe('new') expect(list[0]?.createdAt).toBe(2000) }) it('enforces a FIFO cap at maxSubs, evicting the oldest', () => { const store = loadSubscriptionStore(tmpFile(), 2) store.add(rec('https://push/1')) store.add(rec('https://push/2')) store.add(rec('https://push/3')) expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2', 'https://push/3']) }) }) describe('SubscriptionStore — remove / prune', () => { it('removes a record by endpoint and is a no-op for unknown endpoints', () => { const store = loadSubscriptionStore(tmpFile(), 50) store.add(rec('https://push/1')) store.add(rec('https://push/2')) store.remove('https://push/1') store.remove('https://push/absent') expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2']) }) it('prunes a batch of dead endpoints', () => { const store = loadSubscriptionStore(tmpFile(), 50) store.add(rec('https://push/1')) store.add(rec('https://push/2')) store.add(rec('https://push/3')) store.prune(['https://push/1', 'https://push/3']) expect(store.list().map((r) => r.endpoint)).toEqual(['https://push/2']) }) }) describe('SubscriptionStore — persist', () => { it('writes the file with 0600 permissions and round-trips through load', async () => { const path = tmpFile() const store = loadSubscriptionStore(path, 50) store.add(rec('https://push/1', 11)) store.add(rec('https://push/2', 22)) await store.persist() const mode = statSync(path).mode & 0o777 expect(mode).toBe(0o600) const onDisk = JSON.parse(readFileSync(path, 'utf8')) expect(onDisk.map((r: PushSubscriptionRecord) => r.endpoint)).toEqual([ 'https://push/1', 'https://push/2', ]) const reloaded = loadSubscriptionStore(path, 50) expect(reloaded.list().map((r) => r.endpoint)).toEqual(['https://push/1', 'https://push/2']) }) it('logs but does not throw when the destination is unwritable', async () => { const err = vi.spyOn(console, 'error').mockImplementation(() => {}) // a path whose parent directory does not exist const store = loadSubscriptionStore(join(tmpFile(), 'nope', 'subs.json'), 50) store.add(rec('https://push/1')) await expect(store.persist()).resolves.toBeUndefined() expect(err).toHaveBeenCalled() }) })