From ef558b65be751e276f0e19e622f3b2c691585401 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 20:50:35 -0400 Subject: [PATCH] test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/fixtures/vapid.ts: static TEST_VAPID keypair for offline unit tests - tests/lib/pushDispatcher.test.ts: RED — 410/404 prune + 201/5xx no-delete - tests/lib/pushCoalescer.test.ts: RED — burst coalesce fires once with count=N; excludeUserId - tests/broker/reminderScheduler.test.ts: RED — shared+timed filter; dedup by (uid,minuteBucket) - tests/lib/eventChangeDispatcher.test.ts: RED — create/meaningful-update fires; description-only silent; actor excluded - tests/routes/push.test.ts: RED — POST 201/401; DELETE removes rows; GET vapid-public-key - test/setup.ts: import pushSubscriptions + add db.delete(pushSubscriptions) in afterEach - all 5 RED files fail on missing-module (correct; implementations in Plans 05-02..05-06) --- apps/api/test/setup.ts | 6 +- .../tests/broker/reminderScheduler.test.ts | 136 +++++++++++++++ apps/api/tests/fixtures/vapid.ts | 15 ++ .../tests/lib/eventChangeDispatcher.test.ts | 131 ++++++++++++++ apps/api/tests/lib/pushCoalescer.test.ts | 75 ++++++++ apps/api/tests/lib/pushDispatcher.test.ts | 96 +++++++++++ apps/api/tests/routes/push.test.ts | 162 ++++++++++++++++++ 7 files changed, 619 insertions(+), 2 deletions(-) create mode 100644 apps/api/tests/broker/reminderScheduler.test.ts create mode 100644 apps/api/tests/fixtures/vapid.ts create mode 100644 apps/api/tests/lib/eventChangeDispatcher.test.ts create mode 100644 apps/api/tests/lib/pushCoalescer.test.ts create mode 100644 apps/api/tests/lib/pushDispatcher.test.ts create mode 100644 apps/api/tests/routes/push.test.ts diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index b88b0eb..17aba84 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -17,11 +17,12 @@ import { afterEach } from 'vitest' import { db } from '../src/db/client.js' -import { lists, listItems, listShares } from '../src/db/schema.js' +import { lists, listItems, listShares, pushSubscriptions } from '../src/db/schema.js' /** - * Truncate list tables in FK-safe order after each test. + * Truncate list and push tables in FK-safe order after each test. * list_items and list_shares have FK to lists; delete children first. + * pushSubscriptions has FK to users via user_id; deleted before lists (no FK to lists). * Called automatically via afterEach — no per-test setup needed. */ afterEach(async () => { @@ -29,6 +30,7 @@ afterEach(async () => { // Delete child rows first to avoid FK constraint violations await db.delete(listItems) await db.delete(listShares) + await db.delete(pushSubscriptions) await db.delete(lists) } catch { // DB may not be available in pure-unit test runs (no DB_HOST configured). diff --git a/apps/api/tests/broker/reminderScheduler.test.ts b/apps/api/tests/broker/reminderScheduler.test.ts new file mode 100644 index 0000000..d7b9864 --- /dev/null +++ b/apps/api/tests/broker/reminderScheduler.test.ts @@ -0,0 +1,136 @@ +/** + * RED scaffold — reminderScheduler (Plan 05-06 turns this GREEN). + * + * Asserts that the reminder scan: + * - Selects ONLY shared (isShared=true) AND timed (allDay=false) events + * in the [now+14min, now+16min] window (D-05/D-06/D-07) + * - Does NOT dispatch reminders for all-day events (D-07) + * - Does NOT dispatch reminders for non-shared (personal) events (D-05) + * - Does NOT dispatch the same (eventUid, minuteBucket) twice within the same minute + * + * These tests fail now because reminderScheduler.ts does not yet exist. + * Run: pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Mock the DB so we can control what events are returned +vi.mock('../../src/db/client.js', () => ({ + db: { + select: vi.fn(), + }, +})) + +// Mock pushDispatcher so no real push occurs +vi.mock('../../src/lib/pushDispatcher.js', () => ({ + dispatchPush: vi.fn().mockResolvedValue(undefined), +})) + +describe('reminderScheduler — shared+timed event filtering (D-05/D-06/D-07)', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('does not dispatch reminders for all-day events (D-07)', async () => { + const now = new Date('2026-06-15T10:00:00Z') + vi.setSystemTime(now) + + const { db } = await import('../../src/db/client.js') + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js') + + // Simulate: query returns an all-day event (allDay=true) + // The scheduler should have filtered this out in SQL — return empty results + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }), + } as never) + + await runReminderCheck() + + expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled() + }) + + it('does not dispatch reminders for non-shared (personal) calendar events (D-05)', async () => { + const now = new Date('2026-06-15T10:00:00Z') + vi.setSystemTime(now) + + const { db } = await import('../../src/db/client.js') + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js') + + // SQL WHERE clause must include isShared=true; no personal events returned + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }), + } as never) + + await runReminderCheck() + + expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled() + }) + + it('does not dispatch the same (eventUid, minuteBucket) pair twice within the same run', async () => { + const now = new Date('2026-06-15T10:00:00Z') + vi.setSystemTime(now) + + const { db } = await import('../../src/db/client.js') + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js') + + const sharedTimedEvent = { + uid: 'event-uid-123', + title: 'Team standup', + dtstartUtc: new Date('2026-06-15T10:15:00Z'), // 15 min from now + allDay: false, + isShared: true, + subscriptions: [], + } + + vi.mocked(db.select) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([sharedTimedEvent]), + }), + }), + }), + } as never) + .mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([sharedTimedEvent]), + }), + }), + }), + } as never) + + // First run — should dispatch + await runReminderCheck() + const firstCallCount = vi.mocked(dispatchPush).mock.calls.length + + // Second run in the same minute — same (uid, minuteBucket) — should NOT dispatch again + await runReminderCheck() + const secondCallCount = vi.mocked(dispatchPush).mock.calls.length + + expect(secondCallCount).toBe(firstCallCount) + }) +}) diff --git a/apps/api/tests/fixtures/vapid.ts b/apps/api/tests/fixtures/vapid.ts new file mode 100644 index 0000000..62d2f2e --- /dev/null +++ b/apps/api/tests/fixtures/vapid.ts @@ -0,0 +1,15 @@ +/** + * Static test VAPID keypair fixture. + * + * Used by unit tests that need a VAPID key triple without generating keys at + * runtime or touching the network. These are test-only values — they are NOT + * the production keys and must never be used outside tests. + * + * The keypair was generated once with `crypto.subtle.generateKey` (P-256/ECDH) + * and inlined here so tests are deterministic and offline-safe. + */ +export const TEST_VAPID = { + publicKey: 'BIr9cwAc5L5ZBuY6RazVpjZfIzaAAY_dXDvaMOgM8_nGO8HSyr-WsEoxsmvhG9hWJDK-Mn07rjAFnr9S8fa2w48', + privateKey: 'IjVM8QjjFqDI9_-lhJDmG9yhPpgcrEtKmM-GP1DLiyc', + subject: 'mailto:test@familysync.test', +} as const diff --git a/apps/api/tests/lib/eventChangeDispatcher.test.ts b/apps/api/tests/lib/eventChangeDispatcher.test.ts new file mode 100644 index 0000000..9b0e25b --- /dev/null +++ b/apps/api/tests/lib/eventChangeDispatcher.test.ts @@ -0,0 +1,131 @@ +/** + * RED scaffold — eventChangeDispatcher (Plan 05-05 turns this GREEN). + * + * Asserts that dispatchEventChange: + * - Fires for new events (operation='create') + * - Fires for updated events with meaningful changes: time/date/title/location (D-04) + * - Does NOT fire for description-only edits (D-04) + * - Excludes the actor's own push subscriptions (D-03) + * + * These tests fail now because eventChangeDispatcher.ts does not yet exist. + * Run: pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock DB +vi.mock('../../src/db/client.js', () => ({ + db: { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }, +})) + +// Mock pushDispatcher +vi.mock('../../src/lib/pushDispatcher.js', () => ({ + dispatchPush: vi.fn().mockResolvedValue(undefined), +})) + +describe('eventChangeDispatcher — trigger conditions (D-04)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + it('dispatches for a new event (operation=create)', async () => { + const { db } = await import('../../src/db/client.js') + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js') + + const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' } + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([otherUserSub]), + }), + } as never) + + await dispatchEventChange( + { uid: 'new-event-uid', title: 'Doctor appointment', operation: 'create' }, + /* actorUserId */ 1, + ) + + expect(vi.mocked(dispatchPush)).toHaveBeenCalled() + }) + + it('dispatches for an event with a title change', async () => { + const { db } = await import('../../src/db/client.js') + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js') + + const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' } + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([otherUserSub]), + }), + } as never) + + await dispatchEventChange( + { + uid: 'event-uid-1', + title: 'Renamed Event', + operation: 'update', + changedFields: ['title'], + }, + /* actorUserId */ 1, + ) + + expect(vi.mocked(dispatchPush)).toHaveBeenCalled() + }) + + it('does NOT dispatch for a description-only edit (D-04)', async () => { + const { db } = await import('../../src/db/client.js') + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js') + + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }, + ]), + }), + } as never) + + await dispatchEventChange( + { + uid: 'event-uid-2', + title: 'Team lunch', + operation: 'update', + changedFields: ['description'], // description-only — must NOT fire + }, + /* actorUserId */ 1, + ) + + expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled() + }) + + it('excludes the actor user subscriptions from dispatch (D-03)', async () => { + const { db } = await import('../../src/db/client.js') + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js') + + // DB returns only the actor's own subscription (userId 1) + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { id: 1, userId: 1, endpoint: 'https://push.example.com/sub/1', p256dh: 'x', auth: 'y' }, + ]), + }), + } as never) + + await dispatchEventChange( + { uid: 'event-uid-3', title: 'Soccer practice', operation: 'create' }, + /* actorUserId */ 1, // actor is userId=1 — their subscription must be excluded + ) + + // No subscriptions remain after excluding the actor — nothing dispatched + expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/tests/lib/pushCoalescer.test.ts b/apps/api/tests/lib/pushCoalescer.test.ts new file mode 100644 index 0000000..dbee525 --- /dev/null +++ b/apps/api/tests/lib/pushCoalescer.test.ts @@ -0,0 +1,75 @@ +/** + * RED scaffold — pushCoalescer (Plan 05-03 turns this GREEN). + * + * Asserts that coalesceListPush: + * - Fires the dispatch function exactly ONCE when N calls arrive within the coalesce window + * - Passes the correct count (N) to the dispatch function + * - Passes the actor's own userId as excludeUserId to suppress self-notifications (D-03) + * + * These tests fail now because pushCoalescer.ts does not yet exist. + * Run: pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +describe('pushCoalescer — list-change burst coalescing (D-01)', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses N rapid coalesceListPush calls into a single dispatch with count=N', async () => { + const dispatch = vi.fn().mockResolvedValue(undefined) + const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js') + + const listId = 1 + const actorId = 10 + const N = 5 + + for (let i = 0; i < N; i++) { + coalesceListPush(listId, actorId, dispatch) + } + + // Advance time past the coalesce window + await vi.runAllTimersAsync() + + expect(dispatch).toHaveBeenCalledTimes(1) + const [calledListId, calledActorId, calledCount] = dispatch.mock.calls[0] + expect(calledListId).toBe(listId) + expect(calledCount).toBe(N) + }) + + it('passes the actor userId as excludeUserId so the actor does not notify themselves (D-03)', async () => { + const dispatch = vi.fn().mockResolvedValue(undefined) + const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js') + + const listId = 2 + const actorId = 99 + + coalesceListPush(listId, actorId, dispatch) + await vi.runAllTimersAsync() + + expect(dispatch).toHaveBeenCalledTimes(1) + const [, calledActorId] = dispatch.mock.calls[0] + expect(calledActorId).toBe(actorId) + }) + + it('fires separate dispatches for different lists independently', async () => { + const dispatch = vi.fn().mockResolvedValue(undefined) + const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js') + + coalesceListPush(1, 10, dispatch) + coalesceListPush(1, 10, dispatch) + coalesceListPush(2, 10, dispatch) // different list + coalesceListPush(2, 10, dispatch) + + await vi.runAllTimersAsync() + + // Two separate dispatches — one per distinct listId + expect(dispatch).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/api/tests/lib/pushDispatcher.test.ts b/apps/api/tests/lib/pushDispatcher.test.ts new file mode 100644 index 0000000..c0a37c9 --- /dev/null +++ b/apps/api/tests/lib/pushDispatcher.test.ts @@ -0,0 +1,96 @@ +/** + * RED scaffold — pushDispatcher (Plan 05-02 turns this GREEN). + * + * Asserts that dispatchPush: + * - Deletes the push_subscriptions row when the push service returns 410 (Gone) + * - Deletes the push_subscriptions row when the push service returns 404 (Not Found) + * - Does NOT delete the row on 201 (success) + * - Does NOT delete the row on transient 5xx errors + * + * These tests fail now because pushDispatcher.ts does not yet exist. + * Run: pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { PushSubscription } from '../../src/lib/pushDispatcher.js' + +// Mock web-push so no real network calls occur +vi.mock('web-push', () => ({ + default: { + sendNotification: vi.fn(), + setVapidDetails: vi.fn(), + }, +})) + +// Mock the DB module so we can assert on deletes without a real database +vi.mock('../../src/db/client.js', () => ({ + db: { + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }, +})) + +const FAKE_SUB: PushSubscription = { + id: 1, + userId: 42, + endpoint: 'https://push.example.com/sub/abc', + p256dh: 'fake_p256dh', + auth: 'fake_auth', +} + +describe('pushDispatcher — 410/404 subscription pruning', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('deletes the subscription row when the push service returns 410 (Gone)', async () => { + const webpush = (await import('web-push')).default + const { db } = await import('../../src/db/client.js') + vi.mocked(webpush.sendNotification).mockRejectedValueOnce( + Object.assign(new Error('Gone'), { statusCode: 410 }), + ) + + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' }) + + expect(vi.mocked(db.delete)).toHaveBeenCalled() + }) + + it('deletes the subscription row when the push service returns 404 (Not Found)', async () => { + const webpush = (await import('web-push')).default + const { db } = await import('../../src/db/client.js') + vi.mocked(webpush.sendNotification).mockRejectedValueOnce( + Object.assign(new Error('Not Found'), { statusCode: 404 }), + ) + + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' }) + + expect(vi.mocked(db.delete)).toHaveBeenCalled() + }) + + it('does NOT delete the row on 201 success', async () => { + const webpush = (await import('web-push')).default + const { db } = await import('../../src/db/client.js') + vi.mocked(webpush.sendNotification).mockResolvedValueOnce({ statusCode: 201, body: '', headers: {} }) + + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' }) + + expect(vi.mocked(db.delete)).not.toHaveBeenCalled() + }) + + it('does NOT delete the row on transient 5xx error', async () => { + const webpush = (await import('web-push')).default + const { db } = await import('../../src/db/client.js') + vi.mocked(webpush.sendNotification).mockRejectedValueOnce( + Object.assign(new Error('Service Unavailable'), { statusCode: 503 }), + ) + + const { dispatchPush } = await import('../../src/lib/pushDispatcher.js') + await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' }) + + expect(vi.mocked(db.delete)).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/tests/routes/push.test.ts b/apps/api/tests/routes/push.test.ts new file mode 100644 index 0000000..e832012 --- /dev/null +++ b/apps/api/tests/routes/push.test.ts @@ -0,0 +1,162 @@ +/** + * RED scaffold — push routes (Plan 05-04 turns this GREEN). + * + * Asserts: + * - POST /api/push/subscription persists a row scoped to the authed user + * - POST /api/push/subscription returns 401 when unauthenticated + * - DELETE /api/push/subscription removes all rows for the caller's userId + * - GET /api/push/vapid-public-key returns { publicKey } (unauthenticated) + * + * These tests fail now because apps/api/src/routes/push.ts does not yet exist. + * Run: pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts + * + * Uses the same mock boilerplate as lists.test.ts. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { db } from '../../src/db/client.js' +import { users, pushSubscriptions } from '../../src/db/schema.js' + +// --------------------------------------------------------------------------- +// Dev-bypass mock: inject a specific user ID as the "logged-in" user. +// --------------------------------------------------------------------------- + +let currentDevUserId = 1 + +vi.mock('../../src/auth/devBypass.js', () => ({ + devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise) => { + c.set('user', { id: currentDevUserId }) + await next() + }, +})) + +vi.mock('@hono/oidc-auth', () => ({ + oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise) => next(), + processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }), + getAuth: () => null, +})) + +// --------------------------------------------------------------------------- +// Seed helpers +// --------------------------------------------------------------------------- + +async function seedUser(label: string): Promise { + const [result] = await db.insert(users).values({ + oidcIss: 'https://auth.test', + oidcSub: `sub-${label}-${randomUUID()}`, + displayName: `User ${label}`, + color: '#4A90D9', + }).$returningId() + return result.id +} + +// --------------------------------------------------------------------------- +// Import `app` lazily (after mocks are registered) +// --------------------------------------------------------------------------- + +async function getApp() { + const { app } = await import('../../src/index.js') + return app +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function jsonRequest(method: string, path: string, body?: unknown): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) +} + +function makeSubscriptionBody() { + return { + endpoint: `https://push.example.com/sub/${randomUUID()}`, + keys: { + p256dh: 'BNbxV8eFzxF7rPv3fakekey==', + auth: 'fakeauthtoken==', + }, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(async () => { + // Users are seeded fresh per test; setup.ts truncates pushSubscriptions in afterEach +}) + +describe('GET /api/push/vapid-public-key', () => { + it('returns { publicKey } without authentication', async () => { + process.env.VAPID_PUBLIC_KEY = 'test_public_key_value' + const app = await getApp() + const res = await app.fetch(new Request('http://localhost/api/push/vapid-public-key')) + expect(res.status).toBe(200) + const body = (await res.json()) as { publicKey: string } + expect(typeof body.publicKey).toBe('string') + }) +}) + +describe('POST /api/push/subscription', () => { + it('returns 401 when unauthenticated', async () => { + // Temporarily override the mock to simulate unauthenticated state + vi.mocked(vi.getMockImplementation).mockImplementation?.(() => undefined) + + // Replace devBypass mock to inject no user (simulate 401) + vi.doMock('../../src/auth/devBypass.js', () => ({ + devAuthBypass: () => async (_c: unknown, next: () => Promise) => next(), + })) + vi.doMock('../../src/auth/middleware.js', () => ({ + getAuth: () => null, + })) + + const { app: freshApp } = await import('../../src/index.js?v=unauth') + const res = await freshApp.fetch(jsonRequest('POST', '/api/push/subscription', makeSubscriptionBody())) + expect(res.status).toBe(401) + }) + + it('persists a push_subscriptions row scoped to the authed user', async () => { + const userId = await seedUser('alice') + currentDevUserId = userId + const app = await getApp() + + const body = makeSubscriptionBody() + const res = await app.fetch(jsonRequest('POST', '/api/push/subscription', body)) + expect(res.status).toBe(201) + + const rows = await db + .select() + .from(pushSubscriptions) + .where( + (await import('drizzle-orm')).eq(pushSubscriptions.userId, userId), + ) + expect(rows).toHaveLength(1) + expect(rows[0].endpoint).toBe(body.endpoint) + }) +}) + +describe('DELETE /api/push/subscription', () => { + it("removes the caller's subscription rows", async () => { + const userId = await seedUser('bob') + currentDevUserId = userId + const app = await getApp() + + // First subscribe + await app.fetch(jsonRequest('POST', '/api/push/subscription', makeSubscriptionBody())) + + // Then unsubscribe + const res = await app.fetch(jsonRequest('DELETE', '/api/push/subscription')) + expect(res.status).toBe(200) + + const { eq } = await import('drizzle-orm') + const rows = await db + .select() + .from(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)) + expect(rows).toHaveLength(0) + }) +})