From ef4b1157b344fdbc819fc31037b53f7a0f468a6a Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 13:17:35 -0400 Subject: [PATCH] test(04-05): server-side reorder ordering + rank precision tests (LIST-03, D-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/api/src/lib/rank.test.ts | 44 +++++++++ apps/api/tests/routes/lists.test.ts | 133 ++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/apps/api/src/lib/rank.test.ts b/apps/api/src/lib/rank.test.ts index 721f0db..b367493 100644 --- a/apps/api/src/lib/rank.test.ts +++ b/apps/api/src/lib/rank.test.ts @@ -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) + }) }) diff --git a/apps/api/tests/routes/lists.test.ts b/apps/api/tests/routes/lists.test.ts index 9944c9f..51fdea9 100644 --- a/apps/api/tests/routes/lists.test.ts +++ b/apps/api/tests/routes/lists.test.ts @@ -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) + }) +})