/** * 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) }) })