test(04-04): add failing tests for item CRUD endpoints + rank helpers
- Add rank.test.ts: unit tests for rankForAppend/rankBetween (RED - no impl yet) - Extend lists.test.ts with item route tests: POST /:id/items, GET /:id/items, PATCH /list-items/:id (per-field LWW D-08), DELETE /list-items/:id (D-09) - Import listItems from schema; add seedItem helper - Tests cover: fractional rank assignment (D-13), exact-one-field refine (T-04-07), uncheck rank recompute, access gating T-04-05, delete-wins no resurrection D-09
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { db } from '../../src/db/client.js'
|
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.
|
// 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')
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user