test(05-05): add failing tests for listChangeDispatcher — RED gate

- burst coalescing: N calls → 1 dispatchPush to non-actor with count=N
- D-03 self-suppression: actor's own subscription never dispatched
- T-05-14 access scoping: unrelated user (no owner/share) excluded
- empty audience (no other members): no dispatch, no crash
- empty audience (other member has no subscription): no dispatch, no crash
This commit is contained in:
Lucas Berger
2026-06-09 21:20:34 -04:00
parent 60c247d8ed
commit 97f7026095
@@ -0,0 +1,213 @@
/**
* RED scaffold — listChangeDispatcher (Plan 05-05 turns this GREEN).
*
* Asserts that notifyListChange:
* - Calls dispatchPush exactly ONCE per subscriber who is NOT the actor,
* after a burst of calls coalesces within the window.
* - Self-suppression: the actor never receives a push (D-03).
* - Access scoping: a third user with no owner/share access is never dispatched.
* - Empty audience (no other accessible members or no subscriptions): no dispatch,
* no crash.
*
* Uses a real dev MariaDB (same pattern as lists.test.ts) for DB seeding.
* Mocks dispatchPush via vi.mock to assert recipients without network.
* Uses vi.useFakeTimers to drive the coalescer window synchronously.
*
* Run:
* set -a; . ./.env 2>/dev/null; set +a
* export DB_HOST=127.0.0.1
* pnpm --filter @familysync/api exec vitest run tests/lib/listChangeDispatcher.test.ts
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { randomUUID } from 'node:crypto'
import { db } from '../../src/db/client.js'
import { users, lists, listShares, pushSubscriptions } from '../../src/db/schema.js'
// ---------------------------------------------------------------------------
// Mock dispatchPush so tests assert recipients without making real VAPID calls.
// ---------------------------------------------------------------------------
vi.mock('../../src/lib/pushDispatcher.js', () => ({
dispatchPush: vi.fn().mockResolvedValue(undefined),
}))
// ---------------------------------------------------------------------------
// Seed helpers
// ---------------------------------------------------------------------------
async function seedUser(label: string, displayName?: string): Promise<number> {
const [result] = await db.insert(users).values({
oidcIss: 'https://auth.test',
oidcSub: `sub-${label}-${randomUUID()}`,
displayName: displayName ?? `User ${label}`,
color: '#4A90D9',
}).$returningId()
return result.id
}
async function seedList(ownerId: number, name: string, isShared = true): Promise<number> {
const [result] = await db.insert(lists).values({ ownerId, name, isShared }).$returningId()
return result.id
}
async function seedShare(listId: number, userId: number): Promise<void> {
await db.insert(listShares).values({ listId, userId })
}
async function seedSubscription(userId: number, suffix = ''): Promise<number> {
const [result] = await db.insert(pushSubscriptions).values({
userId,
endpoint: `https://push.example.com/${userId}${suffix}`,
p256dh: 'fake-p256dh-key',
auth: 'fake-auth',
}).$returningId()
return result.id
}
// ---------------------------------------------------------------------------
// Import helpers (lazy, after mocks registered)
// ---------------------------------------------------------------------------
async function getDispatchPushMock() {
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
return vi.mocked(dispatchPush)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-02)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
})
it('burst of N calls coalesces into exactly one dispatchPush to the non-actor subscriber', async () => {
const actorId = await seedUser('actor-burst', 'Alice')
const otherId = await seedUser('other-burst', 'Bob')
const listId = await seedList(actorId, 'Groceries')
await seedShare(listId, otherId)
// Subscriptions: one for actor, one for other
await seedSubscription(actorId)
const otherSubId = await seedSubscription(otherId)
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
// Three rapid calls within the coalesce window
notifyListChange(listId, actorId)
notifyListChange(listId, actorId)
notifyListChange(listId, actorId)
// Advance past the coalesce window
await vi.runAllTimersAsync()
// Exactly one call to dispatchPush (other's subscription only)
expect(mockDispatch).toHaveBeenCalledTimes(1)
// The called subscription must be the other user's subscription
const [calledSub, notification] = mockDispatch.mock.calls[0]
expect(calledSub.id).toBe(otherSubId)
expect(calledSub.userId).toBe(otherId)
// Notification body must contain actor name and change count
expect(notification.body).toMatch(/Alice/)
expect(notification.body).toMatch(/3/)
// Notification body must NOT contain item text (D-02 generic only)
expect(notification.title).toBeDefined()
})
it('actor is never dispatched to their own subscription (D-03 self-suppression)', async () => {
const actorId = await seedUser('actor-self-suppress', 'Charlie')
const listId = await seedList(actorId, 'Private List')
// Actor subscribes, but there are no other accessible members → audience is empty
await seedSubscription(actorId)
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
notifyListChange(listId, actorId)
await vi.runAllTimersAsync()
// Actor is the only accessible member; after excluding actorId, audience is empty
expect(mockDispatch).not.toHaveBeenCalled()
})
it('unrelated user with no access is never dispatched (T-05-14 access scoping)', async () => {
const actorId = await seedUser('actor-scope', 'Dave')
const memberId = await seedUser('member-scope', 'Eve')
const unrelatedId = await seedUser('unrelated-scope', 'Frank')
const listId = await seedList(actorId, 'Scoped List')
await seedShare(listId, memberId)
await seedSubscription(actorId)
await seedSubscription(memberId)
// Unrelated user also has a subscription — must never be dispatched
await seedSubscription(unrelatedId)
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
notifyListChange(listId, actorId)
await vi.runAllTimersAsync()
// Only member (Eve) should be dispatched; unrelated (Frank) must NOT receive push
expect(mockDispatch).toHaveBeenCalledTimes(1)
const [calledSub] = mockDispatch.mock.calls[0]
expect(calledSub.userId).toBe(memberId)
expect(calledSub.userId).not.toBe(unrelatedId)
})
it('empty audience (no other members) → no dispatch, no crash', async () => {
const actorId = await seedUser('actor-no-other', 'Grace')
const listId = await seedList(actorId, 'Solo List')
// No shares; actor-only list
await seedSubscription(actorId)
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
notifyListChange(listId, actorId)
await vi.runAllTimersAsync()
expect(mockDispatch).not.toHaveBeenCalled()
})
it('empty audience (non-actor member has no subscription) → no dispatch, no crash', async () => {
const actorId = await seedUser('actor-no-sub', 'Heidi')
const otherId = await seedUser('other-no-sub', 'Ivan')
const listId = await seedList(actorId, 'No Sub List')
await seedShare(listId, otherId)
// Actor has a subscription, other does NOT
await seedSubscription(actorId)
// Deliberately no subscription for otherId
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
notifyListChange(listId, actorId)
await vi.runAllTimersAsync()
// Other has no subscription → no push dispatched
expect(mockDispatch).not.toHaveBeenCalled()
})
})