diff --git a/apps/api/src/lib/rank.test.ts b/apps/api/src/lib/rank.test.ts new file mode 100644 index 0000000..721f0db --- /dev/null +++ b/apps/api/src/lib/rank.test.ts @@ -0,0 +1,78 @@ +/** + * Unit tests for rank.ts — fractional-indexing helpers (D-13). + * + * Pure function tests, no DB needed. + * Run: pnpm --filter @familysync/api exec vitest run src/lib/rank.test.ts + */ + +import { describe, it, expect } from 'vitest' +import { rankForAppend, rankBetween } from './rank.js' + +describe('rankForAppend', () => { + it('returns "a0" when list is empty (no existing rank)', () => { + const rank = rankForAppend(null) + expect(rank).toBe('a0') + }) + + it('returns a rank string that sorts AFTER the given last rank', () => { + const lastRank = 'a0' + const newRank = rankForAppend(lastRank) + expect(newRank > lastRank).toBe(true) + }) + + it('multiple appends produce strictly increasing ranks', () => { + let last: string | null = null + const ranks: string[] = [] + for (let i = 0; i < 5; i++) { + const r = rankForAppend(last) + ranks.push(r) + last = r + } + // Each successive rank must be greater than the previous + for (let i = 1; i < ranks.length; i++) { + expect(ranks[i] > ranks[i - 1]).toBe(true) + } + }) +}) + +describe('rankBetween', () => { + it('returns "a0" when both prev and next are null (empty list)', () => { + const rank = rankBetween(null, null) + expect(rank).toBe('a0') + }) + + it('returns a rank that sorts between two existing ranks', () => { + const first = rankForAppend(null) // 'a0' + const second = rankForAppend(first) // 'a1' + const between = rankBetween(first, second) + expect(between > first).toBe(true) + expect(between < second).toBe(true) + }) + + it('returns a rank that sorts AFTER prev when next is null', () => { + const prev = 'a0' + const rank = rankBetween(prev, null) + expect(rank > prev).toBe(true) + }) + + it('returns a rank that sorts BEFORE next when prev is null', () => { + const next = 'a1' + const rank = rankBetween(null, next) + expect(rank < next).toBe(true) + }) + + it('produces lexicographically stable ordering across multiple insertions', () => { + // Simulate inserting between 'a0' and 'a1' repeatedly + const a = 'a0' + const b = 'a1' + const c = rankBetween(a, b) + const d = rankBetween(a, c) + const e = rankBetween(c, b) + + // All four ranks should be orderable + expect(a < d).toBe(true) + expect(d < c).toBe(true) + expect(c < e).toBe(true) + expect(e < b).toBe(true) + }) +}) diff --git a/apps/api/tests/routes/lists.test.ts b/apps/api/tests/routes/lists.test.ts index 59d3741..9944c9f 100644 --- a/apps/api/tests/routes/lists.test.ts +++ b/apps/api/tests/routes/lists.test.ts @@ -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 { + 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) + }) +})