Milestone v1.0: FamilySync MVP #1
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user