From 97f7026095eb3e8a337049e118ad7d9c91e42b70 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 21:20:34 -0400 Subject: [PATCH] =?UTF-8?q?test(05-05):=20add=20failing=20tests=20for=20li?= =?UTF-8?q?stChangeDispatcher=20=E2=80=94=20RED=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../tests/lib/listChangeDispatcher.test.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 apps/api/tests/lib/listChangeDispatcher.test.ts diff --git a/apps/api/tests/lib/listChangeDispatcher.test.ts b/apps/api/tests/lib/listChangeDispatcher.test.ts new file mode 100644 index 0000000..45db0c8 --- /dev/null +++ b/apps/api/tests/lib/listChangeDispatcher.test.ts @@ -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 { + 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() + }) +})