diff --git a/server/db/repositories/pushSubscriptions.ts b/server/db/repositories/pushSubscriptions.ts new file mode 100644 index 0000000..b6d31a3 --- /dev/null +++ b/server/db/repositories/pushSubscriptions.ts @@ -0,0 +1,48 @@ +import { eq } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import type * as schema from '../schema' +import { pushSubscriptions } from '../schema' + +type Db = NodePgDatabase + +export interface NewPushSubscription { + endpoint: string + p256dh: string + auth: string + reporterEmail: string +} + +/** + * Store (or refresh) a device's push subscription (F8). Keyed by `endpoint`: + * re-subscribing the same device rotates its keys in place, while a different + * device is kept as a separate row so the owner can enrol several phones. + */ +export async function saveSubscription(db: Db, sub: NewPushSubscription) { + const [row] = await db + .insert(pushSubscriptions) + .values(sub) + .onConflictDoUpdate({ + target: pushSubscriptions.endpoint, + set: { p256dh: sub.p256dh, auth: sub.auth, reporterEmail: sub.reporterEmail }, + }) + .returning() + return row! +} + +/** All stored subscriptions — the fan-out target when a defect is filed (T11). */ +export async function listSubscriptions(db: Db) { + return db.select().from(pushSubscriptions) +} + +/** + * Remove a subscription by endpoint and report how many rows went. Called when + * the push service rejects an endpoint with HTTP 410 (Gone), pruning the stale + * device so we stop sending to it. + */ +export async function deleteSubscriptionByEndpoint(db: Db, endpoint: string): Promise { + const deleted = await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.endpoint, endpoint)) + .returning({ id: pushSubscriptions.id }) + return deleted.length +} diff --git a/tests/push-subscriptions.spec.ts b/tests/push-subscriptions.spec.ts new file mode 100644 index 0000000..504a4e9 --- /dev/null +++ b/tests/push-subscriptions.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest' +import { setupTestDb, truncateAll, type TestDb } from './helpers/db' +import { + saveSubscription, + listSubscriptions, + deleteSubscriptionByEndpoint, +} from '../server/db/repositories/pushSubscriptions' + +// T10/F8: the owner enrols one or more devices for Web Push. Subscriptions are +// keyed by their push endpoint so re-subscribing the same device is idempotent, +// distinct devices are kept side by side, and a 410 from the push service +// prunes the stale one (the pruning itself happens when T11 sends). +describe('push subscriptions repository', () => { + let db: TestDb + let pool: { end: () => Promise } + + beforeAll(async () => { + const setup = await setupTestDb() + db = setup.db + pool = setup.pool + }) + + afterAll(async () => { + await pool.end() + }) + + beforeEach(async () => { + await truncateAll(db) + }) + + const subA = { + endpoint: 'https://push.example/device-a', + p256dh: 'p256dh-a', + auth: 'auth-a', + reporterEmail: 'owner@theodo.com', + } + const subB = { + endpoint: 'https://push.example/device-b', + p256dh: 'p256dh-b', + auth: 'auth-b', + reporterEmail: 'owner@theodo.com', + } + + it('stores a subscription', async () => { + await saveSubscription(db, subA) + + const all = await listSubscriptions(db) + expect(all).toHaveLength(1) + expect(all[0]).toMatchObject({ + endpoint: subA.endpoint, + p256dh: subA.p256dh, + auth: subA.auth, + reporterEmail: subA.reporterEmail, + }) + }) + + it('keeps a second device as a separate subscription', async () => { + await saveSubscription(db, subA) + await saveSubscription(db, subB) + + const endpoints = (await listSubscriptions(db)).map((s) => s.endpoint).sort() + expect(endpoints).toEqual([subA.endpoint, subB.endpoint]) + }) + + it('re-subscribing the same endpoint updates in place rather than duplicating', async () => { + await saveSubscription(db, subA) + await saveSubscription(db, { ...subA, p256dh: 'rotated-key', auth: 'rotated-auth' }) + + const all = await listSubscriptions(db) + expect(all).toHaveLength(1) + expect(all[0]).toMatchObject({ p256dh: 'rotated-key', auth: 'rotated-auth' }) + }) + + it('prunes a subscription by endpoint (the 410 path)', async () => { + await saveSubscription(db, subA) + await saveSubscription(db, subB) + + const removed = await deleteSubscriptionByEndpoint(db, subA.endpoint) + + expect(removed).toBe(1) + expect((await listSubscriptions(db)).map((s) => s.endpoint)).toEqual([subB.endpoint]) + }) + + it('pruning an unknown endpoint is a no-op', async () => { + await saveSubscription(db, subA) + + const removed = await deleteSubscriptionByEndpoint(db, 'https://push.example/gone') + + expect(removed).toBe(0) + expect(await listSubscriptions(db)).toHaveLength(1) + }) +})