company-todos.test.ts
Language TypeScript11.2 KB290 lines
/*** GET /todos/company: the company-wide sweep.** Every assertion here is pinned to something measured against the live API,* not to a spec document. The spec has been wrong (to-do sort casing, and a* swagger several endpoints stale), so the fixture is the contract.*/import { describe, expect, it, vi } from 'vitest'import { HttpClient } from '../src/http.js'import { Roster } from '../src/roster.js'import { TodosResource } from '../src/resources/todos.js'import { fromCursor, iterateCursor, collect, type CursorPage } from '../src/paginate.js'import { NinetySchemaError } from '../src/errors.js'import {RawCompanyTodoSchema,assignmentGroups,assignmentSpread,collapseSeries,companyOwnerName,companyTodoToRaw,normalizeTodo,sharedLinkAudit,UNASSIGNED,UNKNOWN_PERSON,type Todo,} from '../src/types.js'import page1 from './fixtures/todos.company.json' with { type: 'json' }import page2 from './fixtures/todos.company.page2.json' with { type: 'json' }const CTX = { endpoint: 'GET /todos/company' }const NAMES: Record<string, string> = {'a568c7b30679efe19e6ed3a1': 'Person One','4e77bc9877e856992de26b64': 'Person Two','3e894807d73d5a39b03fb411': 'Person Three','e923a6ea5a28e32cb4cfa27e': 'Person Four',}const resolve = (id: string | null | undefined) =>!id ? UNASSIGNED : NAMES[id] ? { id, name: NAMES[id]! } : UNKNOWN_PERSON(id)/** Build a Todo from the thin fixture shape, the way company() does. */function todosFrom(fixture: { data: unknown[] }): Todo[] {return fixture.data.map((r) => RawCompanyTodoSchema.parse(r)).map((r) => normalizeTodo(companyTodoToRaw(r), resolve, { now: new Date('2026-08-24T12:00:00Z') }))}// --- the wire shape ---------------------------------------------------------describe('RawCompanyTodoSchema against the captured shape', () => {it('parses every row and keeps `id`, not `_id`', () => {for (const row of [...page1.data, ...page2.data]) {const parsed = RawCompanyTodoSchema.parse(row)expect(parsed.id).toBeTruthy()expect(parsed).not.toHaveProperty('_id')}})it('confirms the fields that are ABSENT on an open company to-do', () => {// This is why `completed=false` returns zero rows: the field does not exist,// so equality against false matches nothing. Documented here so nobody// "fixes" the missing filter by adding it back to the query.for (const row of page1.data) {for (const absent of ['completed', 'archived', 'deleted', 'isPersonal', 'seriesId', 'milestoneId']) {expect(row).not.toHaveProperty(absent)}}})it('reads the owner name inline, with no roster join', () => {const first = RawCompanyTodoSchema.parse(page1.data[0])expect(companyOwnerName(first)).toBe('Person One')})})describe('companyTodoToRaw', () => {it('maps id -> _id so one normalizer serves both endpoints', () => {const raw = companyTodoToRaw(RawCompanyTodoSchema.parse(page1.data[0]))expect(raw._id).toBe('f3eeab9c18c2f9c02a4c7c7f')})it('asserts the four states the endpoint guarantees rather than guessing', () => {const raw = companyTodoToRaw(RawCompanyTodoSchema.parse(page1.data[0]))expect(raw.completed).toBe(false)expect(raw.archived).toBe(false)expect(raw.deleted).toBe(false)expect(raw.isPersonal).toBe(false)})it('leaves genuinely unknowable fields undefined, not false', () => {const raw = companyTodoToRaw(RawCompanyTodoSchema.parse(page1.data[0]))expect(raw.seriesId).toBeUndefined()expect(raw.milestoneId).toBeUndefined()})})// --- cursor pagination ------------------------------------------------------describe('fromCursor', () => {it('reads {data, pagination.nextToken}', () => {const page = fromCursor<unknown>(page1, CTX)expect(page.items).toHaveLength(6)expect(page.nextToken).toBe('NmE1MWViMzllZDlmZWVhYWYxNDkyNjI3')})it('treats a null nextToken as exhausted', () => {expect(fromCursor<unknown>(page2, CTX).nextToken).toBeNull()})it('treats an empty-string token as exhausted, not as a valid cursor', () => {const page = fromCursor<unknown>({ data: [], pagination: { nextToken: '' } }, CTX)expect(page.nextToken).toBeNull()})it('rejects the bare-array shape rather than silently returning nothing', () => {expect(() => fromCursor<unknown>([], CTX)).toThrow(NinetySchemaError)})it('rejects an envelope whose data is not an array', () => {expect(() => fromCursor<unknown>({ data: { nope: true } }, CTX)).toThrow(NinetySchemaError)})})describe('iterateCursor', () => {it('threads nextToken back and stops on null', async () => {const seen: (string | undefined)[] = []const pages: Record<string, CursorPage<number>> = {first: { items: [1, 2], nextToken: 'a' },a: { items: [3], nextToken: null },}const out = await collect(iterateCursor<number>(async (token) => {seen.push(token)return pages[token ?? 'first']!}),)expect(out).toEqual([1, 2, 3])expect(seen).toEqual([undefined, 'a'])})it('throws instead of looping forever when the cursor does not advance', async () => {await expect(collect(iterateCursor<number>(async () => ({ items: [1], nextToken: 'stuck' }))),).rejects.toThrow(/same nextToken twice/)})it('honours the page cap', async () => {let n = 0await expect(collect(iterateCursor<number>(async () => ({ items: [n], nextToken: `t${n++}` }), { maxPages: 3 }),),).rejects.toThrow(/safety cap/)})})// --- the finding that changes a number on the page --------------------------describe('shared assignments vs recurrence', () => {const todos = [...todosFrom(page1), ...todosFrom(page2)]it('groups one commitment carried by three people into a single commitment', () => {const groups = assignmentGroups(todos)const checklist = groups.find((g) => g.title.startsWith('Refresh the proof approval checklist'))expect(checklist?.todos).toHaveLength(3)expect(checklist?.owners.map((o) => o.name).sort()).toEqual(['Person One', 'Person Three', 'Person Two'])expect(checklist?.shared).toBe(true)})it('normalizes whitespace and case so "Weekly Product " matches "Weekly Product"', () => {const groups = assignmentGroups(todos)const weekly = groups.filter((g) => g.title.toLowerCase().includes('weekly'))expect(weekly).toHaveLength(1)expect(weekly[0]!.owners).toHaveLength(2)})it('reports assignments and commitments as two different numbers', () => {// The live shape of this was 31 assignments / 24 commitments.const spread = assignmentSpread(todos)expect(spread.assignments).toBe(9)expect(spread.commitments).toBe(5)expect(spread.sharedCommitments).toBe(3)expect(spread.maxOwners).toBe(3)})it('marks the basis as heuristic so the UI can say so', () => {expect(assignmentGroups(todos).every((g) => g.basis === 'title')).toBe(true)})it('keeps every row for per-person views', () => {// Deduping here would erase two of the three people who owe the checklist work.const mine = todos.filter((t) => t.owner.name === 'Person Two')expect(mine.length).toBeGreaterThan(1)})})describe('collapseSeries is keyed by series AND owner', () => {const base = todosFrom(page1)[0]!it('collapses repeat occurrences for one person', () => {const a = { ...base, id: 'a', seriesId: 's1', dueDate: '2026-08-08T04:59:59Z' }const b = { ...base, id: 'b', seriesId: 's1', dueDate: '2026-08-15T04:59:59Z' }const out = collapseSeries([a, b])expect(out).toHaveLength(1)expect(out[0]!.id).toBe('a') // earliest, because that is the one actually late})it('NEVER merges a shared series across different owners', () => {// The regression this guards: seven titles among the live rows were one commitment on// two or three people. Collapsing them would erase who owes what.const a = { ...base, id: 'a', seriesId: 's1', owner: { id: 'u1', name: 'Person One' } }const b = { ...base, id: 'b', seriesId: 's1', owner: { id: 'u2', name: 'Person Two' } }const out = collapseSeries([a, b])expect(out).toHaveLength(2)expect(out.map((t) => t.owner.name).sort()).toEqual(['Person One', 'Person Two'])})it('passes non-recurring to-dos straight through', () => {expect(collapseSeries(todosFrom(page2))).toHaveLength(3)})})describe('sharedLinkAudit', () => {it('reports not-linkable when the copies share no series or milestone', () => {const audit = sharedLinkAudit([...todosFrom(page1), ...todosFrom(page2)])expect(audit.sharedGroups).toBe(3)expect(audit.sharesSeriesId).toBe(0)expect(audit.linkable).toBe(false)})it('reports linkable once every shared group agrees on a seriesId', () => {const base = todosFrom(page1)[0]!const a = { ...base, id: 'a', title: 'Shared thing', seriesId: 's9', owner: { id: 'u1', name: 'One' } }const b = { ...base, id: 'b', title: 'Shared thing', seriesId: 's9', owner: { id: 'u2', name: 'Two' } }const audit = sharedLinkAudit([a, b])expect(audit.linkable).toBe(true)expect(audit.detail[0]!.seriesId).toBe('s9')})})// --- the resource, against a stubbed transport ------------------------------describe('TodosResource.company', () => {function resourceReturning(...responses: unknown[]) {const fetchImpl = vi.fn()for (const r of responses) {fetchImpl.mockResolvedValueOnce(new Response(JSON.stringify(r), { status: 200, headers: { 'Content-Type': 'application/json' } }),)}const http = new HttpClient({ token: 't', fetchImpl: fetchImpl as unknown as typeof fetch, rps: 1000 })return { resource: new TodosResource(http, new Roster(http), {}), fetchImpl }}it('walks both pages and returns every row', async () => {const { resource } = resourceReturning(page1, page2)const todos = await resource.company()expect(todos).toHaveLength(9)})it('sends NO boolean filters, since they are broken on this endpoint', async () => {// completed=false returns 0 rows; completed=false&archived=false returns MORE// than unfiltered. Both measured live. The correct call sends neither.const { resource, fetchImpl } = resourceReturning(page2)await resource.company()const url = new URL(fetchImpl.mock.calls[0]![0] as string)expect(url.pathname).toBe('/v1/todos/company')expect(url.searchParams.get('completed')).toBeNull()expect(url.searchParams.get('archived')).toBeNull()expect(url.searchParams.get('deleted')).toBeNull()expect(url.searchParams.get('limit')).toBe('100')})it('threads the cursor into the second request', async () => {const { resource, fetchImpl } = resourceReturning(page1, page2)await resource.company()const second = new URL(fetchImpl.mock.calls[1]![0] as string)expect(second.searchParams.get('nextToken')).toBe('NmE1MWViMzllZDlmZWVhYWYxNDkyNjI3')})it('resolves owner names with zero GET /users traffic', async () => {const { resource, fetchImpl } = resourceReturning(page2)const todos = await resource.company()expect(todos.map((t) => t.owner.name)).toContain('Person One')for (const call of fetchImpl.mock.calls) {expect(String(call[0])).not.toContain('/users/')}})})