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:
Lucas Berger
2026-06-09 13:17:35 -04:00
parent d49c5f1c9c
commit ef4b1157b3
2 changed files with 177 additions and 0 deletions
+44
View File
@@ -75,4 +75,48 @@ describe('rankBetween', () => {
expect(c < e).toBe(true)
expect(e < b).toBe(true)
})
/**
* Precision regression test — LIST-03, Pitfall 2.
*
* Repeated "zipper" inserts (always between the first two items) must produce
* strictly increasing, unique, non-empty rank strings across many iterations.
* Float-based approaches would exhaust precision around iteration 52; the
* fractional-indexing string approach degrades gracefully by growing string
* length instead.
*/
it('repeated mid-point inserts produce unique strictly-increasing ranks over 100 iterations (precision — Pitfall 2)', () => {
const ranks: string[] = [rankBetween(null, null), rankBetween(null, null)]
// Set up: two items with known ranks
ranks[0] = rankBetween(null, null) // 'a0'
ranks[1] = rankBetween(ranks[0], null) // 'a1'
// Insert 100 times between the first item and the second item
// This is the worst-case "zipper" pattern — always inserting at the same gap
for (let i = 0; i < 100; i++) {
const newRank = rankBetween(ranks[0], ranks[1])
// Must be strictly between
expect(newRank > ranks[0]).toBe(true)
expect(newRank < ranks[1]).toBe(true)
// Must be a non-empty string
expect(newRank.length).toBeGreaterThan(0)
// Must be unique (not equal to any existing rank)
expect(ranks).not.toContain(newRank)
// New item goes at index 1 (after first, before old second) — shift old items
ranks.splice(1, 0, newRank)
}
// Verify the final list is fully sorted ASC
for (let i = 1; i < ranks.length; i++) {
expect(ranks[i] > ranks[i - 1]).toBe(true)
}
})
it('rank between two neighbors is strictly between them (D-13 reorder contract)', () => {
const prev = 'a0'
const next = 'a3'
const middle = rankBetween(prev, next)
expect(middle > prev).toBe(true)
expect(middle < next).toBe(true)
})
})
+133
View File
@@ -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)
})
})