Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
2 changed files with 355 additions and 1 deletions
Showing only changes of commit b1dc9b8048 - Show all commits
+78
View File
@@ -0,0 +1,78 @@
/**
* Unit tests for rank.ts — fractional-indexing helpers (D-13).
*
* Pure function tests, no DB needed.
* Run: pnpm --filter @familysync/api exec vitest run src/lib/rank.test.ts
*/
import { describe, it, expect } from 'vitest'
import { rankForAppend, rankBetween } from './rank.js'
describe('rankForAppend', () => {
it('returns "a0" when list is empty (no existing rank)', () => {
const rank = rankForAppend(null)
expect(rank).toBe('a0')
})
it('returns a rank string that sorts AFTER the given last rank', () => {
const lastRank = 'a0'
const newRank = rankForAppend(lastRank)
expect(newRank > lastRank).toBe(true)
})
it('multiple appends produce strictly increasing ranks', () => {
let last: string | null = null
const ranks: string[] = []
for (let i = 0; i < 5; i++) {
const r = rankForAppend(last)
ranks.push(r)
last = r
}
// Each successive rank must be greater than the previous
for (let i = 1; i < ranks.length; i++) {
expect(ranks[i] > ranks[i - 1]).toBe(true)
}
})
})
describe('rankBetween', () => {
it('returns "a0" when both prev and next are null (empty list)', () => {
const rank = rankBetween(null, null)
expect(rank).toBe('a0')
})
it('returns a rank that sorts between two existing ranks', () => {
const first = rankForAppend(null) // 'a0'
const second = rankForAppend(first) // 'a1'
const between = rankBetween(first, second)
expect(between > first).toBe(true)
expect(between < second).toBe(true)
})
it('returns a rank that sorts AFTER prev when next is null', () => {
const prev = 'a0'
const rank = rankBetween(prev, null)
expect(rank > prev).toBe(true)
})
it('returns a rank that sorts BEFORE next when prev is null', () => {
const next = 'a1'
const rank = rankBetween(null, next)
expect(rank < next).toBe(true)
})
it('produces lexicographically stable ordering across multiple insertions', () => {
// Simulate inserting between 'a0' and 'a1' repeatedly
const a = 'a0'
const b = 'a1'
const c = rankBetween(a, b)
const d = rankBetween(a, c)
const e = rankBetween(c, b)
// All four ranks should be orderable
expect(a < d).toBe(true)
expect(d < c).toBe(true)
expect(c < e).toBe(true)
expect(e < b).toBe(true)
})
})
+277 -1
View File
@@ -19,7 +19,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import { db } from '../../src/db/client.js'
import { users, lists, listShares } from '../../src/db/schema.js'
import { users, lists, listShares, listItems } from '../../src/db/schema.js'
// ---------------------------------------------------------------------------
// Dev-bypass mock: inject a specific user ID as the "logged-in" user.
@@ -449,3 +449,279 @@ describe('PATCH /api/lists/:id (authorized update)', () => {
expect(body.name).toBe('Sharee Renamed')
})
})
// ---------------------------------------------------------------------------
// Item route tests (LIST-02) — D-05/D-06/D-08/D-09/D-13
// ---------------------------------------------------------------------------
async function seedItem(
listId: number,
text: string,
rank: string,
checked = false,
): Promise<number> {
const { eq } = await import('drizzle-orm')
const [result] = await db.insert(listItems).values({ listId, text, rank, checked }).$returningId()
return result.id
}
// ---------------------------------------------------------------------------
// POST /api/lists/:id/items — add item (LIST-02, D-13)
// ---------------------------------------------------------------------------
describe('POST /api/lists/:id/items — add item (LIST-02, D-13)', () => {
it('inserts an item and returns id/listId/text/checked/rank', async () => {
const ownerId = await seedUser('post-item-owner')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Item List', false)
const app = await getApp()
const res = await app.request(jsonRequest('POST', `/api/lists/${listId}/items`, { text: 'milk' }))
expect(res.status).toBe(201)
const body = await res.json() as { id: number; listId: number; text: string; checked: boolean; rank: string }
expect(body.id).toBeDefined()
expect(body.listId).toBe(listId)
expect(body.text).toBe('milk')
expect(body.checked).toBe(false)
expect(body.rank).toBe('a0') // first item → generateKeyBetween(null, null) = 'a0'
})
it('first item in empty list gets rank "a0" (generateKeyBetween(null, null))', async () => {
const ownerId = await seedUser('post-item-first-rank')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Rank List', false)
const app = await getApp()
const res = await app.request(jsonRequest('POST', `/api/lists/${listId}/items`, { text: 'first' }))
expect(res.status).toBe(201)
const body = await res.json() as { rank: string }
expect(body.rank).toBe('a0')
})
it('second item rank sorts after first item rank (append to active bottom)', async () => {
const ownerId = await seedUser('post-item-second-rank')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Rank Append List', false)
await seedItem(listId, 'first', 'a0')
const app = await getApp()
const res = await app.request(jsonRequest('POST', `/api/lists/${listId}/items`, { text: 'second' }))
expect(res.status).toBe(201)
const body = await res.json() as { rank: string }
expect(body.rank > 'a0').toBe(true)
})
it('rejects an empty text with 400 (zod validation)', async () => {
const ownerId = await seedUser('post-item-empty-text')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Zod Item List', false)
const app = await getApp()
const res = await app.request(jsonRequest('POST', `/api/lists/${listId}/items`, { text: '' }))
expect(res.status).toBe(400)
})
it('returns 403 when caller has no list access', async () => {
const ownerId = await seedUser('post-item-403-owner')
const otherId = await seedUser('post-item-403-other')
const listId = await seedList(ownerId, 'Private List Items', false)
currentDevUserId = otherId
const app = await getApp()
const res = await app.request(jsonRequest('POST', `/api/lists/${listId}/items`, { text: 'sneaky' }))
expect(res.status).toBe(403)
})
})
// ---------------------------------------------------------------------------
// GET /api/lists/:id/items — access-gated item fetch (LIST-02)
// ---------------------------------------------------------------------------
describe('GET /api/lists/:id/items — access-gated fetch (LIST-02)', () => {
it('returns items ordered by rank ASC for list owner', async () => {
const ownerId = await seedUser('get-items-owner')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Get Items', false)
await seedItem(listId, 'first', 'a0')
await seedItem(listId, 'second', 'a1')
const app = await getApp()
const res = await app.request(`/api/lists/${listId}/items`)
expect(res.status).toBe(200)
const body = await res.json() as { items: Array<{ id: number; text: string; checked: boolean; rank: string }> }
expect(body.items.length).toBe(2)
expect(body.items[0].text).toBe('first')
expect(body.items[1].text).toBe('second')
// Shape check
expect(body.items[0]).toHaveProperty('id')
expect(body.items[0]).toHaveProperty('listId')
expect(body.items[0]).toHaveProperty('checked')
expect(body.items[0]).toHaveProperty('rank')
})
it('returns 403 for non-member (T-04-05)', async () => {
const ownerId = await seedUser('get-items-403-owner')
const otherId = await seedUser('get-items-403-other')
const listId = await seedList(ownerId, 'Private Items', false)
currentDevUserId = otherId
const app = await getApp()
const res = await app.request(`/api/lists/${listId}/items`)
expect(res.status).toBe(403)
})
})
// ---------------------------------------------------------------------------
// PATCH /api/list-items/:id — per-field LWW update (D-08)
// ---------------------------------------------------------------------------
describe('PATCH /api/list-items/:id — per-field LWW (D-08)', () => {
it('PATCH { checked: true } updates only checked field', async () => {
const ownerId = await seedUser('patch-item-check')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Patch Check', false)
const itemId = await seedItem(listId, 'bread', 'a0')
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { checked: true }))
expect(res.status).toBe(200)
const body = await res.json() as { checked: boolean; text: string }
expect(body.checked).toBe(true)
expect(body.text).toBe('bread') // text unchanged
})
it('PATCH { checked: false } (uncheck) moves item to active bottom — rank after last active', async () => {
const ownerId = await seedUser('patch-item-uncheck')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Uncheck Rank', false)
// Two active items
await seedItem(listId, 'alpha', 'a0')
const lastActiveId = await seedItem(listId, 'beta', 'a1')
// One checked item (to uncheck)
const checkedItemId = await seedItem(listId, 'checked-one', 'Zz', true)
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${checkedItemId}`, { checked: false }))
expect(res.status).toBe(200)
const body = await res.json() as { checked: boolean; rank: string }
expect(body.checked).toBe(false)
// rank must sort AFTER the last active item's rank ('a1')
expect(body.rank > 'a1').toBe(true)
})
it('PATCH { text: "eggs" } updates only text field (LWW D-08)', async () => {
const ownerId = await seedUser('patch-item-text')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Patch Text', false)
const itemId = await seedItem(listId, 'old text', 'a0')
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { text: 'new text' }))
expect(res.status).toBe(200)
const body = await res.json() as { text: string; checked: boolean }
expect(body.text).toBe('new text')
expect(body.checked).toBe(false) // checked unchanged
})
it('PATCH with two fields is rejected by zod refine (exactly-one-field D-08, T-04-07)', async () => {
const ownerId = await seedUser('patch-item-two-fields')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Two Fields', false)
const itemId = await seedItem(listId, 'item', 'a0')
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { checked: true, text: 'nope' }))
// @hono/zod-validator returns 400 on schema violations (matches events.ts convention)
expect(res.status).toBe(400)
})
it('PATCH with empty body is rejected (no fields)', async () => {
const ownerId = await seedUser('patch-item-empty-body')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Empty Body', false)
const itemId = await seedItem(listId, 'item', 'a0')
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, {}))
expect(res.status).toBe(400)
})
it('returns 403 when caller has no list access (T-04-05)', async () => {
const ownerId = await seedUser('patch-item-403-owner')
const otherId = await seedUser('patch-item-403-other')
const listId = await seedList(ownerId, 'Private Items Patch', false)
const itemId = await seedItem(listId, 'secret', 'a0')
currentDevUserId = otherId
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { checked: true }))
expect(res.status).toBe(403)
})
})
// ---------------------------------------------------------------------------
// DELETE /api/list-items/:id — delete-wins (D-06/D-09)
// ---------------------------------------------------------------------------
describe('DELETE /api/list-items/:id — delete-wins (D-06/D-09)', () => {
it('owner can delete an item', async () => {
const ownerId = await seedUser('del-item-owner')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Delete Item', false)
const itemId = await seedItem(listId, 'doomed item', 'a0')
const app = await getApp()
const res = await app.request(new Request(`http://localhost/api/list-items/${itemId}`, { method: 'DELETE' }))
expect(res.status).toBe(200)
// Verify gone from DB
const { eq } = await import('drizzle-orm')
const remaining = await db.select().from(listItems).where(eq(listItems.id, itemId))
expect(remaining.length).toBe(0)
})
it('sharee can delete an item in a shared list', async () => {
const ownerId = await seedUser('del-item-sharee-owner')
const shareeId = await seedUser('del-item-sharee-sharee')
const listId = await seedList(ownerId, 'Shared Del List', true)
await shareList(listId, shareeId)
const itemId = await seedItem(listId, 'sharee deletes', 'a0')
currentDevUserId = shareeId
const app = await getApp()
const res = await app.request(new Request(`http://localhost/api/list-items/${itemId}`, { method: 'DELETE' }))
expect(res.status).toBe(200)
})
it('returns 403 when caller has no list access (T-04-05)', async () => {
const ownerId = await seedUser('del-item-403-owner')
const otherId = await seedUser('del-item-403-other')
const listId = await seedList(ownerId, 'Private Del List', false)
const itemId = await seedItem(listId, 'can t delete', 'a0')
currentDevUserId = otherId
const app = await getApp()
const res = await app.request(new Request(`http://localhost/api/list-items/${itemId}`, { method: 'DELETE' }))
expect(res.status).toBe(403)
// Verify item still exists (delete-wins semantics: only succeeds if authorized)
const { eq } = await import('drizzle-orm')
const remaining = await db.select().from(listItems).where(eq(listItems.id, itemId))
expect(remaining.length).toBe(1)
})
it('delete-wins: PATCH on deleted item affects zero rows (no resurrection D-09)', async () => {
const ownerId = await seedUser('del-wins-owner')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Del Wins', false)
const itemId = await seedItem(listId, 'ephemeral', 'a0')
// Delete first
const app = await getApp()
await app.request(new Request(`http://localhost/api/list-items/${itemId}`, { method: 'DELETE' }))
// PATCH after delete should 404 (no row to update)
const patchRes = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { checked: true }))
expect(patchRes.status).toBe(404)
})
})