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
This commit is contained in:
Lucas Berger
2026-06-09 12:15:19 -04:00
parent 60745b3281
commit 2d250afce2
2 changed files with 171 additions and 11 deletions
+88
View File
@@ -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<number> {
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<number> {
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<void> {
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)
})
})
+83 -11
View File
@@ -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)
})
})