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. * 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)', () => { describe('listEmitter — scoped fan-out correctness (D-04)', () => {
it.todo('publishListEvent emits only to subscribers for the matching listId') const makeEvent = (listId: number): ListEvent => ({
it.todo('publishListEvent does NOT emit to subscribers for a different listId') type: 'item:added',
it.todo('subscribeListEvents returns an unsubscribe function that stops future events') listId,
it.todo('unsubscribed handler is not called after unsubscribe()') payload: { id: 1, text: 'milk', checked: false },
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)')
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)
})
}) })