From 2d250afce2ee6fef2e0d13dbd45056f42003bb1f Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 12:15:19 -0400 Subject: [PATCH] test(04-02): add failing listEmitter + listAccess tests (RED gate) - listEmitter.test.ts: 5 real assertions replacing it.todo stubs (Tests 1-4: scoped delivery, D-04 negative cross-list isolation, unsub, multi-handler, D-18 scale) - listAccess.test.ts: 4 DB-backed assertions (Tests 5-8) (owned lists, shared via list_shares, D-04 negative private exclusion, dedupe) - Both files fail: listEmitter.ts and listAccess.ts do not exist yet --- apps/api/tests/lib/listAccess.test.ts | 88 ++++++++++++++++++++++++ apps/api/tests/lib/listEmitter.test.ts | 94 +++++++++++++++++++++++--- 2 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 apps/api/tests/lib/listAccess.test.ts diff --git a/apps/api/tests/lib/listAccess.test.ts b/apps/api/tests/lib/listAccess.test.ts new file mode 100644 index 0000000..85cae78 --- /dev/null +++ b/apps/api/tests/lib/listAccess.test.ts @@ -0,0 +1,88 @@ +/** + * listAccess — getAccessibleListIds access-scope query tests (D-04). + * + * DB-backed tests. Uses the test DB harness from Plan 01 (test/setup.ts). + * Tests 5-8 verify that getAccessibleListIds returns exactly the right set + * of list IDs for a given user: owned + shared, no more, no less. + * + * Critical: Test 7 is the D-04 negative assertion — a private non-shared list + * owned by another user MUST NOT appear in the result. + * + * Run: pnpm --filter @familysync/api exec vitest run tests/lib/listAccess.test.ts + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import { db } from '../../src/db/client.js' +import { users, lists, listShares } from '../../src/db/schema.js' +import { getAccessibleListIds } from '../../src/lib/listAccess.js' + +// Seed helpers — insert minimal rows and return their IDs. +async function seedUser(suffix: string): Promise { + const [result] = await db.insert(users).values({ + oidcIss: 'https://auth.test', + oidcSub: `sub-${suffix}`, + displayName: `User ${suffix}`, + color: '#000000', + }).$returningId() + return result.id +} + +async function seedList(ownerId: number, isShared = false): Promise { + const [result] = await db.insert(lists).values({ + ownerId, + name: `List ${Math.random()}`, + isShared, + }).$returningId() + return result.id +} + +async function shareList(listId: number, userId: number): Promise { + await db.insert(listShares).values({ listId, userId }) +} + +describe('listAccess — getAccessibleListIds (D-04)', () => { + it('returns ids of lists the user owns (Test 5)', async () => { + const userId = await seedUser('owner-5') + const listId1 = await seedList(userId) + const listId2 = await seedList(userId) + + const ids = await getAccessibleListIds(userId) + + expect(ids).toContain(listId1) + expect(ids).toContain(listId2) + }) + + it('returns ids of lists shared to the user via list_shares (Test 6)', async () => { + const ownerUserId = await seedUser('owner-6') + const sharedUserId = await seedUser('shared-6') + const sharedListId = await seedList(ownerUserId, true) + await shareList(sharedListId, sharedUserId) + + const ids = await getAccessibleListIds(sharedUserId) + + expect(ids).toContain(sharedListId) + }) + + it('does NOT return another user\'s private non-shared list id (Test 7 — D-04 negative)', async () => { + const ownerUserId = await seedUser('owner-7') + const otherUserId = await seedUser('other-7') + const privateListId = await seedList(ownerUserId, false) + // Deliberately NOT sharing privateListId with otherUserId + + const ids = await getAccessibleListIds(otherUserId) + + expect(ids).not.toContain(privateListId) + }) + + it('result has no duplicates when a list is both owned and shared to the owner (Test 8)', async () => { + const userId = await seedUser('owner-8') + const listId = await seedList(userId, true) + // Erroneously share the list back to the owner (degenerate case) + await shareList(listId, userId) + + const ids = await getAccessibleListIds(userId) + + const occurrences = ids.filter((id) => id === listId) + expect(occurrences).toHaveLength(1) + }) +}) diff --git a/apps/api/tests/lib/listEmitter.test.ts b/apps/api/tests/lib/listEmitter.test.ts index e4ceef2..ffee222 100644 --- a/apps/api/tests/lib/listEmitter.test.ts +++ b/apps/api/tests/lib/listEmitter.test.ts @@ -1,21 +1,93 @@ /** - * Wave-0 RED stubs for listEmitter (in-memory EventEmitter fan-out). + * listEmitter — scoped fan-out correctness tests (D-04). * - * Covers D-04: SSE fan-out MUST be scoped to who can see a list. * These are pure-unit tests — no DB or HTTP server needed. + * Tests 1-4 cover publishListEvent / subscribeListEvents behavior. * - * Downstream plans implement the actual listEmitter.ts module that these stubs test. + * Critical: Test 2 is the D-04 negative assertion — an event published + * for list 2 MUST NOT reach a handler subscribed only to list 1. * - * Run: pnpm --filter @familysync/api test + * Run: pnpm --filter @familysync/api exec vitest run tests/lib/listEmitter.test.ts */ -import { describe, it } from 'vitest' +import { describe, it, expect, vi } from 'vitest' +import { + publishListEvent, + subscribeListEvents, + type ListEvent, +} from '../../src/lib/listEmitter.js' describe('listEmitter — scoped fan-out correctness (D-04)', () => { - it.todo('publishListEvent emits only to subscribers for the matching listId') - it.todo('publishListEvent does NOT emit to subscribers for a different listId') - it.todo('subscribeListEvents returns an unsubscribe function that stops future events') - it.todo('unsubscribed handler is not called after unsubscribe()') - it.todo('multiple subscribers for the same listId all receive the event') - it.todo('emitter handles 100+ concurrent subscribers without error (D-18 scale check)') + const makeEvent = (listId: number): ListEvent => ({ + type: 'item:added', + listId, + payload: { id: 1, text: 'milk', checked: false }, + }) + + it('publishListEvent delivers to a subscriber of the matching listId (Test 1)', () => { + const received: ListEvent[] = [] + const unsub = subscribeListEvents(1, (ev) => received.push(ev)) + + const ev = makeEvent(1) + publishListEvent(1, ev) + + unsub() + expect(received).toHaveLength(1) + expect(received[0]).toBe(ev) + }) + + it('publishListEvent does NOT deliver to a subscriber of a different listId (Test 2 — D-04 negative)', () => { + const received: ListEvent[] = [] + const unsub = subscribeListEvents(1, (ev) => received.push(ev)) + + publishListEvent(2, makeEvent(2)) + + unsub() + expect(received).toHaveLength(0) + }) + + it('the unsubscribe closure stops future delivery (Test 3)', () => { + const received: ListEvent[] = [] + const unsub = subscribeListEvents(3, (ev) => received.push(ev)) + + publishListEvent(3, makeEvent(3)) + unsub() + publishListEvent(3, makeEvent(3)) + + expect(received).toHaveLength(1) + }) + + it('multiple handlers on the same list channel all receive the event (Test 4)', () => { + const calls: number[] = [] + const unsub1 = subscribeListEvents(4, () => calls.push(1)) + const unsub2 = subscribeListEvents(4, () => calls.push(2)) + const unsub3 = subscribeListEvents(4, () => calls.push(3)) + + publishListEvent(4, makeEvent(4)) + + unsub1() + unsub2() + unsub3() + + expect(calls).toHaveLength(3) + expect(calls).toContain(1) + expect(calls).toContain(2) + expect(calls).toContain(3) + }) + + it('emitter handles 100+ concurrent subscribers without MaxListeners error (D-18 scale check)', () => { + const N = 120 + const unsubs: Array<() => void> = [] + const handler = vi.fn() + + for (let i = 0; i < N; i++) { + unsubs.push(subscribeListEvents(5, handler)) + } + + publishListEvent(5, makeEvent(5)) + + unsubs.forEach((u) => u()) + + expect(handler).toHaveBeenCalledTimes(N) + }) })