Milestone v1.0: FamilySync MVP #1
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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