todos.test.ts
Language TypeScript7.8 KB215 lines
/*** Tests against the real wire shape, captured from the live API.* The fixture is the contract; values are sanitized, structure is untouched.*/import { describe, expect, it } from 'vitest'import { toWire, TodosResource } from '../src/resources/todos.js'import { HttpClient } from '../src/http.js'import { Roster } from '../src/roster.js'import {RawTodoSchema,collapseSeries,normalizeTodo,seriesInflation,stripHtml,UNASSIGNED,UNKNOWN_PERSON,type Todo,} from '../src/types.js'import fixture from './fixtures/todos.query.json' with { type: 'json' }const resolve = (id: string | null | undefined) =>!id ? UNASSIGNED : id === '3e894807d73d5a39b03fb411' ? { id, name: 'Ines Carvalho' } : UNKNOWN_PERSON(id)describe('toWire, confirmed against the live API', () => {it('sends page 1 for the first page (page is 1-based)', () => {expect(toWire({}, 0, 25).page).toBe(1)expect(toWire({}, 1, 25).page).toBe(2)})it('does NOT lowercase the sort field', () => {// A live query used sort:"dueDate". Lowercasing it would silently mis-sort.expect(toWire({ sortBy: 'dueDate' }, 0, 25).sort).toBe('dueDate')expect(toWire({}, 0, 25).sort).toBe('dueDate')})it('defaults to open items only', () => {const body = toWire({}, 0, 25)expect(body.completed).toBe(false)expect(body.archived).toBe(false)})it('lets the caller ask for completed items explicitly', () => {expect(toWire({ completed: true }, 0, 25).completed).toBe(true)})it('passes through the confirmed filters and omits the rest', () => {const body = toWire({ teamId: 't1', searchText: 'testimonials', isPersonal: false }, 0, 25)expect(body).toMatchObject({ teamId: 't1', searchText: 'testimonials', isPersonal: false })expect(body).not.toHaveProperty('userId')expect(body).not.toHaveProperty('dueBefore')})})describe('RawTodoSchema against the live fixture', () => {it('parses every row without stripping unknown fields', () => {for (const row of fixture) {const parsed = RawTodoSchema.parse(row)expect(parsed._id).toBeTruthy()expect(parsed).toHaveProperty('userOrdinal') // passthrough kept it}})})describe('normalizeTodo against the live fixture', () => {const todos = fixture.map((r) => normalizeTodo(RawTodoSchema.parse(r), resolve))it('maps the real field names', () => {const t = todos[0]!expect(t.id).toBe('b9954036a9e6c6430fb25939')expect(t.owner.name).toBe('Ines Carvalho')expect(t.teamName).toBe('All Hands')expect(t.dueDate).toBe('2026-08-08T04:59:59.999Z')expect(t.completed).toBe(false)expect(t.repeat).toBe('Weekly')expect(t.seriesId).toBe('c00d32d2dc9473444a1cf427')})it('strips HTML out of the description but keeps the original', () => {const t = todos[0]!expect(t.description).not.toContain('<')expect(t.description).toContain('First item')expect(t.description).toContain('Third & fourth')expect(t.descriptionHtml).toContain('<ul>')})it('never leaks a raw ObjectId as a name', () => {const t = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], userId: 'deadbeef00' }), resolve)expect(t.owner.name).toBe('unknown user')})it('treats "Never" as no recurrence', () => {const t = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], repeat: 'Never' }), resolve)expect(t.repeat).toBeNull()})})describe('stripHtml', () => {it('turns list items into lines', () => {expect(stripHtml('<ul><li>a</li><li>b</li></ul>')).toBe('- a\n- b')})it('decodes the entities Ninety actually emits', () => {expect(stripHtml('A & B')).toBe('A & B')expect(stripHtml('<tag> "q" 's'')).toBe('<tag> "q" \'s\'')})it('drops inline styles without dropping their text', () => {expect(stripHtml('<li style="color: rgba(184, 49, 47, 1)">IMPORTANT</li>')).toBe('- IMPORTANT')})it('handles empty input', () => {expect(stripHtml('')).toBe('')})})describe('collapseSeries and the recurring-to-do double count', () => {const todos = fixture.map((r) => normalizeTodo(RawTodoSchema.parse(r), resolve))it('the fixture really does contain one task twice', () => {expect(todos).toHaveLength(2)expect(new Set(todos.map((t) => t.seriesId)).size).toBe(1)})it('collapses to one row per series', () => {expect(collapseSeries(todos)).toHaveLength(1)})it('keeps the earliest-due occurrence by default, since that is the late one', () => {expect(collapseSeries(todos)[0]!.dueDate).toBe('2026-08-08T04:59:59.999Z')})it('can keep the latest instead', () => {expect(collapseSeries(todos, 'latest')[0]!.dueDate).toBe('2026-08-15T04:59:59.999Z')})it('passes non-recurring to-dos straight through', () => {const oneOff = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], _id: 'x', seriesId: null, repeat: null }),resolve,)expect(collapseSeries([...todos, oneOff])).toHaveLength(2)})it('sorts undated occurrences last rather than treating them as due now', () => {const undated = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], _id: 'y', dueDate: null }), resolve)const kept = collapseSeries([undated, ...todos])[0]!expect(kept.dueDate).toBe('2026-08-08T04:59:59.999Z')})it('reports how much recurrence is inflating the count', () => {expect(seriesInflation(todos)).toEqual({ total: 2, collapsed: 1, duplicates: 1 })})})describe('TodosResource', () => {const http = (fetchImpl: typeof fetch) =>new HttpClient({ token: 't', fetchImpl, sleep: async () => {}, rps: 1000 })const respond = (body: unknown) =>new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })it('harvests createdBy names for free instead of fetching them', async () => {let userCalls = 0const fetchImpl = (async (url: string | URL | Request) => {const u = String(url)if (u.includes('/users/')) {userCalls++return respond({ _id: 'x', fullName: 'Fetched Name' })}return respond(fixture)}) as unknown as typeof fetchconst h = http(fetchImpl)const roster = new Roster(h)const todos = new TodosResource(h, roster)const page = await todos.query({ pageSize: 100 })expect(page.items[0]!.createdBy.name).toBe('Sample Creator')// The creator came free from the payload; only the owner needed a request.expect(userCalls).toBe(1)expect(roster.stats().noted).toBe(1)})it('filters out soft-deleted rows', async () => {const withDeleted = [{ ...fixture[0], deleted: true }, fixture[1]]const fetchImpl = (async (url: string | URL | Request) =>String(url).includes('/users/') ? respond({ _id: 'u', fullName: 'U' }) : respond(withDeleted)) as unknown as typeof fetchconst h = http(fetchImpl)const out = await new TodosResource(h, new Roster(h)).list()expect(out).toHaveLength(1)expect(out[0]!.id).toBe('37c62dc317911ffcf43dc486')})it('applies the unconfirmed owner filter client-side', async () => {const mixed = [fixture[0], { ...fixture[1], userId: 'someone-else' }]const fetchImpl = (async (url: string | URL | Request) =>String(url).includes('/users/') ? respond({ _id: 'u', fullName: 'U' }) : respond(mixed)) as unknown as typeof fetchconst h = http(fetchImpl)const out = await new TodosResource(h, new Roster(h)).list({ userId: '3e894807d73d5a39b03fb411' })expect(out).toHaveLength(1)})})describe('the dashboard invariant', () => {it('no rendered field ever contains a bare ObjectId', () => {const todos: Todo[] = fixture.map((r) => normalizeTodo(RawTodoSchema.parse(r), () => UNKNOWN_PERSON('x')))for (const t of todos) {expect(t.owner.name).not.toMatch(/^[0-9a-f]{24}$/)expect(t.createdBy.name).not.toMatch(/^[0-9a-f]{24}$/)}})})