test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation
- 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)
This commit is contained in:
@@ -17,11 +17,12 @@
|
|||||||
|
|
||||||
import { afterEach } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
import { db } from '../src/db/client.js'
|
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.
|
* 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.
|
* Called automatically via afterEach — no per-test setup needed.
|
||||||
*/
|
*/
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -29,6 +30,7 @@ afterEach(async () => {
|
|||||||
// Delete child rows first to avoid FK constraint violations
|
// Delete child rows first to avoid FK constraint violations
|
||||||
await db.delete(listItems)
|
await db.delete(listItems)
|
||||||
await db.delete(listShares)
|
await db.delete(listShares)
|
||||||
|
await db.delete(pushSubscriptions)
|
||||||
await db.delete(lists)
|
await db.delete(lists)
|
||||||
} catch {
|
} catch {
|
||||||
// DB may not be available in pure-unit test runs (no DB_HOST configured).
|
// DB may not be available in pure-unit test runs (no DB_HOST configured).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Vendored
+15
@@ -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
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void>) => {
|
||||||
|
c.set('user', { id: currentDevUserId })
|
||||||
|
await next()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@hono/oidc-auth', () => ({
|
||||||
|
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
|
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||||
|
getAuth: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Seed helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function seedUser(label: string): Promise<number> {
|
||||||
|
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<void>) => 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user