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