feat(05-05): implement listChangeDispatcher — access-scoped, self-suppressed, coalesced push (NOTIF-02)

- notifyListChange(listId, actorId, windowMs?) wraps coalesceListPush with a
  dispatch closure that resolves actor name + list name from DB, builds
  audience as owner ∪ list_shares MINUS actorId (D-03), and calls
  dispatchPush per accessible subscriber subscription
- D-02 generic copy: '{Actor} made {N} changes to {ListName}' — no item text
- D-03 self-suppression: actorId filtered from audience before subscription load
- T-05-14: audience strictly scoped to list access (owner + list_shares only)
- T-05-15: no item text in notification body
- Empty audience and missing subscriptions are silent no-ops
- Tests: 5/5 GREEN (burst→1 push, self-suppress, access scope, empty audience)
This commit is contained in:
Lucas Berger
2026-06-09 21:24:48 -04:00
parent 97f7026095
commit 69231043e4
2 changed files with 189 additions and 52 deletions
+63 -52
View File
@@ -1,5 +1,5 @@
/**
* RED scaffold — listChangeDispatcher (Plan 05-05 turns this GREEN).
* Integration tests — listChangeDispatcher (Plan 05-05).
*
* Asserts that notifyListChange:
* - Calls dispatchPush exactly ONCE per subscriber who is NOT the actor,
@@ -10,27 +10,20 @@
* 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.
* Mocks dispatchPush via vi.doMock to assert recipients without network.
* Uses a tiny windowMs (10 ms) so the coalescer fires quickly with real timers.
*
* Run:
* set -a; . ./.env 2>/dev/null; set +a
* 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 { describe, it, expect, vi, beforeEach } 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
// ---------------------------------------------------------------------------
@@ -64,28 +57,56 @@ async function seedSubscription(userId: number, suffix = ''): Promise<number> {
return result.id
}
// ---------------------------------------------------------------------------
// Import helpers (lazy, after mocks registered)
// ---------------------------------------------------------------------------
/** Short wait for real-timer coalescer window + async DB queries to settle. */
function sleep(ms: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function getDispatchPushMock() {
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
return vi.mocked(dispatchPush)
/**
* Poll a predicate until it passes or the timeout is exceeded.
* Replaces @testing-library/waitFor — keeps the test deps minimal.
*/
async function pollUntil(
predicate: () => void,
timeoutMs = 3000,
intervalMs = 30,
): Promise<void> {
const deadline = Date.now() + timeoutMs
let lastErr: unknown
while (Date.now() < deadline) {
try {
predicate()
return // predicate passed
} catch (err) {
lastErr = err
}
await sleep(intervalMs)
}
throw lastErr
}
// ---------------------------------------------------------------------------
// Tests
// Use a tiny windowMs (10 ms) so the coalescer fires quickly in real time.
// Use vi.doMock + vi.resetModules before each test so the mock is fresh.
// ---------------------------------------------------------------------------
const WINDOW_MS = 10
describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-02)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.resetModules()
// Re-register the dispatchPush mock after each resetModules so fresh
// dynamic imports of listChangeDispatcher.js get the mocked pushDispatcher.
vi.doMock('../../src/lib/pushDispatcher.js', () => ({
dispatchPush: vi.fn().mockResolvedValue(undefined),
}))
})
afterEach(() => {
vi.useRealTimers()
})
async function getDispatchPushMock() {
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
return vi.mocked(dispatchPush)
}
it('burst of N calls coalesces into exactly one dispatchPush to the non-actor subscriber', async () => {
const actorId = await seedUser('actor-burst', 'Alice')
@@ -98,20 +119,15 @@ describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-
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)
notifyListChange(listId, actorId, WINDOW_MS)
notifyListChange(listId, actorId, WINDOW_MS)
notifyListChange(listId, actorId, WINDOW_MS)
// Advance past the coalesce window
await vi.runAllTimersAsync()
// Exactly one call to dispatchPush (other's subscription only)
expect(mockDispatch).toHaveBeenCalledTimes(1)
// Wait for the coalescer window to expire + DB queries to settle
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1))
// The called subscription must be the other user's subscription
const [calledSub, notification] = mockDispatch.mock.calls[0]
@@ -121,25 +137,25 @@ describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-
// 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)
// Notification must have a title (D-02 — no item text, just generic copy)
expect(notification.title).toBeDefined()
expect(typeof notification.title).toBe('string')
})
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
// Actor subscribes, but there are no other accessible members → audience is empty after D-03
await seedSubscription(actorId)
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
notifyListChange(listId, actorId)
await vi.runAllTimersAsync()
notifyListChange(listId, actorId, WINDOW_MS)
// Wait past the window; actor's subscription must never be dispatched
await sleep(WINDOW_MS + 200)
// Actor is the only accessible member; after excluding actorId, audience is empty
expect(mockDispatch).not.toHaveBeenCalled()
})
@@ -157,15 +173,14 @@ describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-
await seedSubscription(unrelatedId)
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
notifyListChange(listId, actorId)
await vi.runAllTimersAsync()
notifyListChange(listId, actorId, WINDOW_MS)
// Wait for coalescer + DB queries to settle
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1))
// 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)
@@ -178,12 +193,10 @@ describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-
await seedSubscription(actorId)
const mockDispatch = await getDispatchPushMock()
mockDispatch.mockClear()
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
notifyListChange(listId, actorId)
await vi.runAllTimersAsync()
notifyListChange(listId, actorId, WINDOW_MS)
await sleep(WINDOW_MS + 200)
expect(mockDispatch).not.toHaveBeenCalled()
})
@@ -200,12 +213,10 @@ describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-
// 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()
notifyListChange(listId, actorId, WINDOW_MS)
await sleep(WINDOW_MS + 200)
// Other has no subscription → no push dispatched
expect(mockDispatch).not.toHaveBeenCalled()