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