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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user