/** * Integration tests — listChangeDispatcher (Plan 05-05). * * 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.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 * 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 } from 'vitest' import { randomUUID } from 'node:crypto' import { db } from '../../src/db/client.js' import { users, lists, listShares, pushSubscriptions } from '../../src/db/schema.js' // --------------------------------------------------------------------------- // Seed helpers // --------------------------------------------------------------------------- async function seedUser(label: string, displayName?: string): Promise { 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 { const [result] = await db.insert(lists).values({ ownerId, name, isShared }).$returningId() return result.id } async function seedShare(listId: number, userId: number): Promise { await db.insert(listShares).values({ listId, userId }) } async function seedSubscription(userId: number, suffix = ''): Promise { 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 } /** Short wait for real-timer coalescer window + async DB queries to settle. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * 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 { 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.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), })) }) 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') 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() const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js') // Three rapid calls within the coalesce window notifyListChange(listId, actorId, WINDOW_MS) notifyListChange(listId, actorId, WINDOW_MS) notifyListChange(listId, actorId, WINDOW_MS) // 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] 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 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 after D-03 await seedSubscription(actorId) const mockDispatch = await getDispatchPushMock() const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js') notifyListChange(listId, actorId, WINDOW_MS) // Wait past the window; actor's subscription must never be dispatched await sleep(WINDOW_MS + 200) 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() const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js') 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 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() const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js') notifyListChange(listId, actorId, WINDOW_MS) await sleep(WINDOW_MS + 200) 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() const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js') notifyListChange(listId, actorId, WINDOW_MS) await sleep(WINDOW_MS + 200) // Other has no subscription → no push dispatched expect(mockDispatch).not.toHaveBeenCalled() }) })