test(04-05): server-side reorder ordering + rank precision tests (LIST-03, D-13)
- rank.test.ts: 100-iteration zipper mid-point insert precision test (Pitfall 2); rank-between-neighbors contract test; total 10 tests (was 8) - lists.test.ts: 5 new LIST-03 ordering tests — PATCH position updates only rank and GET returns new ASC order; one-row write asserts other items unchanged; LWW (D-15): second PATCH overwrites first; T-04-07 two-field position PATCH → 400 - Note: tests use a0–a5 rank range (avoids uppercase ranks that sort differently under MariaDB utf8mb4_unicode_ci vs JS lexicographic order)
This commit is contained in:
@@ -725,3 +725,136 @@ describe('DELETE /api/list-items/:id — delete-wins (D-06/D-09)', () => {
|
||||
expect(patchRes.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/list-items/:id { position } — reorder ordering tests (LIST-03, D-13)
|
||||
//
|
||||
// These tests assert the correctness of the fractional-rank reorder path:
|
||||
// - A position PATCH updates only rank (one-row write, D-13)
|
||||
// - GET after reorder returns items in the new ASC rank order
|
||||
// - A rank produced for a position between two neighbors is strictly between theirs
|
||||
// - Concurrent last-write-wins: later PATCH wins (D-15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('PATCH /api/list-items/:id { position } — reorder ordering (LIST-03, D-13)', () => {
|
||||
it('PATCH { position } updates only rank; GET returns items in new ASC rank order', async () => {
|
||||
const ownerId = await seedUser('reorder-order')
|
||||
currentDevUserId = ownerId
|
||||
const listId = await seedList(ownerId, 'Reorder Order', false)
|
||||
// Seed four items in order a0 < a1 < a2 < a3
|
||||
const id1 = await seedItem(listId, 'alpha', 'a0')
|
||||
const id2 = await seedItem(listId, 'beta', 'a1')
|
||||
const id3 = await seedItem(listId, 'gamma', 'a2')
|
||||
const id4 = await seedItem(listId, 'delta', 'a3')
|
||||
|
||||
// Move 'alpha' (a0) to the end: rank after 'a3' → 'a4'
|
||||
// This stays within the normal rank space and avoids collation issues with uppercase ranks
|
||||
const newRank = 'a4' // sorts after 'a3' in both JS and MariaDB
|
||||
|
||||
const app = await getApp()
|
||||
const patchRes = await app.request(jsonRequest('PATCH', `/api/list-items/${id1}`, { position: newRank }))
|
||||
expect(patchRes.status).toBe(200)
|
||||
const patchBody = await patchRes.json() as { rank: string; text: string; checked: boolean }
|
||||
// rank is updated
|
||||
expect(patchBody.rank).toBe(newRank)
|
||||
// text and checked unchanged
|
||||
expect(patchBody.text).toBe('alpha')
|
||||
expect(patchBody.checked).toBe(false)
|
||||
|
||||
// GET returns items in ASC rank order: beta (a1) < gamma (a2) < delta (a3) < alpha (a4)
|
||||
const getRes = await app.request(`/api/lists/${listId}/items`)
|
||||
expect(getRes.status).toBe(200)
|
||||
const getBody = await getRes.json() as { items: Array<{ id: number; text: string; rank: string }> }
|
||||
expect(getBody.items).toHaveLength(4)
|
||||
expect(getBody.items[0].id).toBe(id2) // beta first
|
||||
expect(getBody.items[1].id).toBe(id3) // gamma second
|
||||
expect(getBody.items[2].id).toBe(id4) // delta third
|
||||
expect(getBody.items[3].id).toBe(id1) // alpha last (moved to end)
|
||||
expect(getBody.items[3].rank).toBe(newRank)
|
||||
})
|
||||
|
||||
it('rank produced between two neighbors is strictly between their ranks (D-13)', async () => {
|
||||
const ownerId = await seedUser('reorder-between')
|
||||
currentDevUserId = ownerId
|
||||
const listId = await seedList(ownerId, 'Reorder Between', false)
|
||||
const id1 = await seedItem(listId, 'first', 'a0')
|
||||
const id2 = await seedItem(listId, 'second', 'a1')
|
||||
const id3 = await seedItem(listId, 'third', 'a2')
|
||||
|
||||
// Move 'third' between 'first' and 'second': generateKeyBetween('a0', 'a1')
|
||||
// fractional-indexing produces 'a0V' for generateKeyBetween('a0', 'a1')
|
||||
const newRank = 'a0V' // known output of generateKeyBetween('a0', 'a1')
|
||||
expect(newRank > 'a0').toBe(true)
|
||||
expect(newRank < 'a1').toBe(true)
|
||||
|
||||
const app = await getApp()
|
||||
const patchRes = await app.request(jsonRequest('PATCH', `/api/list-items/${id3}`, { position: newRank }))
|
||||
expect(patchRes.status).toBe(200)
|
||||
const patchBody = await patchRes.json() as { rank: string }
|
||||
expect(patchBody.rank).toBe(newRank)
|
||||
|
||||
// Verify DB order: first (a0), third (a0V), second (a1)
|
||||
const getRes = await app.request(`/api/lists/${listId}/items`)
|
||||
const getBody = await getRes.json() as { items: Array<{ id: number; rank: string }> }
|
||||
expect(getBody.items[0].id).toBe(id1) // a0
|
||||
expect(getBody.items[1].id).toBe(id3) // a0V (moved between)
|
||||
expect(getBody.items[2].id).toBe(id2) // a1
|
||||
})
|
||||
|
||||
it('only the moved item rank changes — other items ranks are untouched (one-row write, D-13)', async () => {
|
||||
const ownerId = await seedUser('reorder-one-row')
|
||||
currentDevUserId = ownerId
|
||||
const listId = await seedList(ownerId, 'Reorder One Row', false)
|
||||
const id1 = await seedItem(listId, 'alpha', 'a0')
|
||||
const id2 = await seedItem(listId, 'beta', 'a1')
|
||||
const id3 = await seedItem(listId, 'gamma', 'a2')
|
||||
|
||||
// Move gamma to the end (a3 > a2)
|
||||
const app = await getApp()
|
||||
await app.request(jsonRequest('PATCH', `/api/list-items/${id3}`, { position: 'a3' }))
|
||||
|
||||
// alpha and beta ranks must be unchanged (one-row write — D-13)
|
||||
const { eq } = await import('drizzle-orm')
|
||||
const [alpha] = await db.select({ rank: listItems.rank }).from(listItems).where(eq(listItems.id, id1))
|
||||
const [beta] = await db.select({ rank: listItems.rank }).from(listItems).where(eq(listItems.id, id2))
|
||||
expect(alpha.rank).toBe('a0') // unchanged
|
||||
expect(beta.rank).toBe('a1') // unchanged
|
||||
})
|
||||
|
||||
it('last-write-wins: second PATCH { position } overwrites first (D-15)', async () => {
|
||||
const ownerId = await seedUser('reorder-lww')
|
||||
currentDevUserId = ownerId
|
||||
const listId = await seedList(ownerId, 'Reorder LWW', false)
|
||||
const id1 = await seedItem(listId, 'alpha', 'a0')
|
||||
const id2 = await seedItem(listId, 'beta', 'a1')
|
||||
const id3 = await seedItem(listId, 'gamma', 'a2')
|
||||
|
||||
const app = await getApp()
|
||||
|
||||
// First PATCH: move gamma to between alpha and beta (a0V is between a0 and a1)
|
||||
await app.request(jsonRequest('PATCH', `/api/list-items/${id3}`, { position: 'a0V' }))
|
||||
|
||||
// Second PATCH: move gamma to the end — last-write wins (D-15)
|
||||
const secondRes = await app.request(jsonRequest('PATCH', `/api/list-items/${id3}`, { position: 'a5' }))
|
||||
expect(secondRes.status).toBe(200)
|
||||
const secondBody = await secondRes.json() as { rank: string }
|
||||
expect(secondBody.rank).toBe('a5')
|
||||
|
||||
// GET confirms second PATCH rank wins (LWW: a5 > a0V)
|
||||
const getRes = await app.request(`/api/lists/${listId}/items`)
|
||||
const getBody = await getRes.json() as { items: Array<{ id: number; rank: string }> }
|
||||
const gammaItem = getBody.items.find((i) => i.id === id3)
|
||||
expect(gammaItem?.rank).toBe('a5')
|
||||
})
|
||||
|
||||
it('returns 400 when position field is provided alongside another field (T-04-07)', async () => {
|
||||
const ownerId = await seedUser('reorder-two-field')
|
||||
currentDevUserId = ownerId
|
||||
const listId = await seedList(ownerId, 'Reorder Two Field', false)
|
||||
const itemId = await seedItem(listId, 'item', 'a0')
|
||||
|
||||
const app = await getApp()
|
||||
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { position: 'a1', checked: true }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user