mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

company-todos.test.ts

Language TypeScript11.2 KB290 lines

packages/ninety-client/test/company-todos.test.ts

  1. /**
  2. * GET /todos/company: the company-wide sweep.
  3. *
  4. * Every assertion here is pinned to something measured against the live API,
  5. * not to a spec document. The spec has been wrong (to-do sort casing, and a
  6. * swagger several endpoints stale), so the fixture is the contract.
  7. */
  8. import { describe, expect, it, vi } from 'vitest'
  9. import { HttpClient } from '../src/http.js'
  10. import { Roster } from '../src/roster.js'
  11. import { TodosResource } from '../src/resources/todos.js'
  12. import { fromCursor, iterateCursor, collect, type CursorPage } from '../src/paginate.js'
  13. import { NinetySchemaError } from '../src/errors.js'
  14. import {
  15. RawCompanyTodoSchema,
  16. assignmentGroups,
  17. assignmentSpread,
  18. collapseSeries,
  19. companyOwnerName,
  20. companyTodoToRaw,
  21. normalizeTodo,
  22. sharedLinkAudit,
  23. UNASSIGNED,
  24. UNKNOWN_PERSON,
  25. type Todo,
  26. } from '../src/types.js'
  27. import page1 from './fixtures/todos.company.json' with { type: 'json' }
  28. import page2 from './fixtures/todos.company.page2.json' with { type: 'json' }
  29. const CTX = { endpoint: 'GET /todos/company' }
  30. const NAMES: Record<string, string> = {
  31. 'a568c7b30679efe19e6ed3a1': 'Person One',
  32. '4e77bc9877e856992de26b64': 'Person Two',
  33. '3e894807d73d5a39b03fb411': 'Person Three',
  34. 'e923a6ea5a28e32cb4cfa27e': 'Person Four',
  35. }
  36. const resolve = (id: string | null | undefined) =>
  37. !id ? UNASSIGNED : NAMES[id] ? { id, name: NAMES[id]! } : UNKNOWN_PERSON(id)
  38. /** Build a Todo from the thin fixture shape, the way company() does. */
  39. function todosFrom(fixture: { data: unknown[] }): Todo[] {
  40. return fixture.data
  41. .map((r) => RawCompanyTodoSchema.parse(r))
  42. .map((r) => normalizeTodo(companyTodoToRaw(r), resolve, { now: new Date('2026-08-24T12:00:00Z') }))
  43. }
  44. // --- the wire shape ---------------------------------------------------------
  45. describe('RawCompanyTodoSchema against the captured shape', () => {
  46. it('parses every row and keeps `id`, not `_id`', () => {
  47. for (const row of [...page1.data, ...page2.data]) {
  48. const parsed = RawCompanyTodoSchema.parse(row)
  49. expect(parsed.id).toBeTruthy()
  50. expect(parsed).not.toHaveProperty('_id')
  51. }
  52. })
  53. it('confirms the fields that are ABSENT on an open company to-do', () => {
  54. // This is why `completed=false` returns zero rows: the field does not exist,
  55. // so equality against false matches nothing. Documented here so nobody
  56. // "fixes" the missing filter by adding it back to the query.
  57. for (const row of page1.data) {
  58. for (const absent of ['completed', 'archived', 'deleted', 'isPersonal', 'seriesId', 'milestoneId']) {
  59. expect(row).not.toHaveProperty(absent)
  60. }
  61. }
  62. })
  63. it('reads the owner name inline, with no roster join', () => {
  64. const first = RawCompanyTodoSchema.parse(page1.data[0])
  65. expect(companyOwnerName(first)).toBe('Person One')
  66. })
  67. })
  68. describe('companyTodoToRaw', () => {
  69. it('maps id -> _id so one normalizer serves both endpoints', () => {
  70. const raw = companyTodoToRaw(RawCompanyTodoSchema.parse(page1.data[0]))
  71. expect(raw._id).toBe('f3eeab9c18c2f9c02a4c7c7f')
  72. })
  73. it('asserts the four states the endpoint guarantees rather than guessing', () => {
  74. const raw = companyTodoToRaw(RawCompanyTodoSchema.parse(page1.data[0]))
  75. expect(raw.completed).toBe(false)
  76. expect(raw.archived).toBe(false)
  77. expect(raw.deleted).toBe(false)
  78. expect(raw.isPersonal).toBe(false)
  79. })
  80. it('leaves genuinely unknowable fields undefined, not false', () => {
  81. const raw = companyTodoToRaw(RawCompanyTodoSchema.parse(page1.data[0]))
  82. expect(raw.seriesId).toBeUndefined()
  83. expect(raw.milestoneId).toBeUndefined()
  84. })
  85. })
  86. // --- cursor pagination ------------------------------------------------------
  87. describe('fromCursor', () => {
  88. it('reads {data, pagination.nextToken}', () => {
  89. const page = fromCursor<unknown>(page1, CTX)
  90. expect(page.items).toHaveLength(6)
  91. expect(page.nextToken).toBe('NmE1MWViMzllZDlmZWVhYWYxNDkyNjI3')
  92. })
  93. it('treats a null nextToken as exhausted', () => {
  94. expect(fromCursor<unknown>(page2, CTX).nextToken).toBeNull()
  95. })
  96. it('treats an empty-string token as exhausted, not as a valid cursor', () => {
  97. const page = fromCursor<unknown>({ data: [], pagination: { nextToken: '' } }, CTX)
  98. expect(page.nextToken).toBeNull()
  99. })
  100. it('rejects the bare-array shape rather than silently returning nothing', () => {
  101. expect(() => fromCursor<unknown>([], CTX)).toThrow(NinetySchemaError)
  102. })
  103. it('rejects an envelope whose data is not an array', () => {
  104. expect(() => fromCursor<unknown>({ data: { nope: true } }, CTX)).toThrow(NinetySchemaError)
  105. })
  106. })
  107. describe('iterateCursor', () => {
  108. it('threads nextToken back and stops on null', async () => {
  109. const seen: (string | undefined)[] = []
  110. const pages: Record<string, CursorPage<number>> = {
  111. first: { items: [1, 2], nextToken: 'a' },
  112. a: { items: [3], nextToken: null },
  113. }
  114. const out = await collect(
  115. iterateCursor<number>(async (token) => {
  116. seen.push(token)
  117. return pages[token ?? 'first']!
  118. }),
  119. )
  120. expect(out).toEqual([1, 2, 3])
  121. expect(seen).toEqual([undefined, 'a'])
  122. })
  123. it('throws instead of looping forever when the cursor does not advance', async () => {
  124. await expect(
  125. collect(iterateCursor<number>(async () => ({ items: [1], nextToken: 'stuck' }))),
  126. ).rejects.toThrow(/same nextToken twice/)
  127. })
  128. it('honours the page cap', async () => {
  129. let n = 0
  130. await expect(
  131. collect(
  132. iterateCursor<number>(async () => ({ items: [n], nextToken: `t${n++}` }), { maxPages: 3 }),
  133. ),
  134. ).rejects.toThrow(/safety cap/)
  135. })
  136. })
  137. // --- the finding that changes a number on the page --------------------------
  138. describe('shared assignments vs recurrence', () => {
  139. const todos = [...todosFrom(page1), ...todosFrom(page2)]
  140. it('groups one commitment carried by three people into a single commitment', () => {
  141. const groups = assignmentGroups(todos)
  142. const checklist = groups.find((g) => g.title.startsWith('Refresh the proof approval checklist'))
  143. expect(checklist?.todos).toHaveLength(3)
  144. expect(checklist?.owners.map((o) => o.name).sort()).toEqual(['Person One', 'Person Three', 'Person Two'])
  145. expect(checklist?.shared).toBe(true)
  146. })
  147. it('normalizes whitespace and case so "Weekly Product " matches "Weekly Product"', () => {
  148. const groups = assignmentGroups(todos)
  149. const weekly = groups.filter((g) => g.title.toLowerCase().includes('weekly'))
  150. expect(weekly).toHaveLength(1)
  151. expect(weekly[0]!.owners).toHaveLength(2)
  152. })
  153. it('reports assignments and commitments as two different numbers', () => {
  154. // The live shape of this was 31 assignments / 24 commitments.
  155. const spread = assignmentSpread(todos)
  156. expect(spread.assignments).toBe(9)
  157. expect(spread.commitments).toBe(5)
  158. expect(spread.sharedCommitments).toBe(3)
  159. expect(spread.maxOwners).toBe(3)
  160. })
  161. it('marks the basis as heuristic so the UI can say so', () => {
  162. expect(assignmentGroups(todos).every((g) => g.basis === 'title')).toBe(true)
  163. })
  164. it('keeps every row for per-person views', () => {
  165. // Deduping here would erase two of the three people who owe the checklist work.
  166. const mine = todos.filter((t) => t.owner.name === 'Person Two')
  167. expect(mine.length).toBeGreaterThan(1)
  168. })
  169. })
  170. describe('collapseSeries is keyed by series AND owner', () => {
  171. const base = todosFrom(page1)[0]!
  172. it('collapses repeat occurrences for one person', () => {
  173. const a = { ...base, id: 'a', seriesId: 's1', dueDate: '2026-08-08T04:59:59Z' }
  174. const b = { ...base, id: 'b', seriesId: 's1', dueDate: '2026-08-15T04:59:59Z' }
  175. const out = collapseSeries([a, b])
  176. expect(out).toHaveLength(1)
  177. expect(out[0]!.id).toBe('a') // earliest, because that is the one actually late
  178. })
  179. it('NEVER merges a shared series across different owners', () => {
  180. // The regression this guards: seven titles among the live rows were one commitment on
  181. // two or three people. Collapsing them would erase who owes what.
  182. const a = { ...base, id: 'a', seriesId: 's1', owner: { id: 'u1', name: 'Person One' } }
  183. const b = { ...base, id: 'b', seriesId: 's1', owner: { id: 'u2', name: 'Person Two' } }
  184. const out = collapseSeries([a, b])
  185. expect(out).toHaveLength(2)
  186. expect(out.map((t) => t.owner.name).sort()).toEqual(['Person One', 'Person Two'])
  187. })
  188. it('passes non-recurring to-dos straight through', () => {
  189. expect(collapseSeries(todosFrom(page2))).toHaveLength(3)
  190. })
  191. })
  192. describe('sharedLinkAudit', () => {
  193. it('reports not-linkable when the copies share no series or milestone', () => {
  194. const audit = sharedLinkAudit([...todosFrom(page1), ...todosFrom(page2)])
  195. expect(audit.sharedGroups).toBe(3)
  196. expect(audit.sharesSeriesId).toBe(0)
  197. expect(audit.linkable).toBe(false)
  198. })
  199. it('reports linkable once every shared group agrees on a seriesId', () => {
  200. const base = todosFrom(page1)[0]!
  201. const a = { ...base, id: 'a', title: 'Shared thing', seriesId: 's9', owner: { id: 'u1', name: 'One' } }
  202. const b = { ...base, id: 'b', title: 'Shared thing', seriesId: 's9', owner: { id: 'u2', name: 'Two' } }
  203. const audit = sharedLinkAudit([a, b])
  204. expect(audit.linkable).toBe(true)
  205. expect(audit.detail[0]!.seriesId).toBe('s9')
  206. })
  207. })
  208. // --- the resource, against a stubbed transport ------------------------------
  209. describe('TodosResource.company', () => {
  210. function resourceReturning(...responses: unknown[]) {
  211. const fetchImpl = vi.fn()
  212. for (const r of responses) {
  213. fetchImpl.mockResolvedValueOnce(
  214. new Response(JSON.stringify(r), { status: 200, headers: { 'Content-Type': 'application/json' } }),
  215. )
  216. }
  217. const http = new HttpClient({ token: 't', fetchImpl: fetchImpl as unknown as typeof fetch, rps: 1000 })
  218. return { resource: new TodosResource(http, new Roster(http), {}), fetchImpl }
  219. }
  220. it('walks both pages and returns every row', async () => {
  221. const { resource } = resourceReturning(page1, page2)
  222. const todos = await resource.company()
  223. expect(todos).toHaveLength(9)
  224. })
  225. it('sends NO boolean filters, since they are broken on this endpoint', async () => {
  226. // completed=false returns 0 rows; completed=false&archived=false returns MORE
  227. // than unfiltered. Both measured live. The correct call sends neither.
  228. const { resource, fetchImpl } = resourceReturning(page2)
  229. await resource.company()
  230. const url = new URL(fetchImpl.mock.calls[0]![0] as string)
  231. expect(url.pathname).toBe('/v1/todos/company')
  232. expect(url.searchParams.get('completed')).toBeNull()
  233. expect(url.searchParams.get('archived')).toBeNull()
  234. expect(url.searchParams.get('deleted')).toBeNull()
  235. expect(url.searchParams.get('limit')).toBe('100')
  236. })
  237. it('threads the cursor into the second request', async () => {
  238. const { resource, fetchImpl } = resourceReturning(page1, page2)
  239. await resource.company()
  240. const second = new URL(fetchImpl.mock.calls[1]![0] as string)
  241. expect(second.searchParams.get('nextToken')).toBe('NmE1MWViMzllZDlmZWVhYWYxNDkyNjI3')
  242. })
  243. it('resolves owner names with zero GET /users traffic', async () => {
  244. const { resource, fetchImpl } = resourceReturning(page2)
  245. const todos = await resource.company()
  246. expect(todos.map((t) => t.owner.name)).toContain('Person One')
  247. for (const call of fetchImpl.mock.calls) {
  248. expect(String(call[0])).not.toContain('/users/')
  249. }
  250. })
  251. })