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

todos.test.ts

Language TypeScript7.8 KB215 lines

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

  1. /**
  2. * Tests against the real wire shape, captured from the live API.
  3. * The fixture is the contract; values are sanitized, structure is untouched.
  4. */
  5. import { describe, expect, it } from 'vitest'
  6. import { toWire, TodosResource } from '../src/resources/todos.js'
  7. import { HttpClient } from '../src/http.js'
  8. import { Roster } from '../src/roster.js'
  9. import {
  10. RawTodoSchema,
  11. collapseSeries,
  12. normalizeTodo,
  13. seriesInflation,
  14. stripHtml,
  15. UNASSIGNED,
  16. UNKNOWN_PERSON,
  17. type Todo,
  18. } from '../src/types.js'
  19. import fixture from './fixtures/todos.query.json' with { type: 'json' }
  20. const resolve = (id: string | null | undefined) =>
  21. !id ? UNASSIGNED : id === '3e894807d73d5a39b03fb411' ? { id, name: 'Ines Carvalho' } : UNKNOWN_PERSON(id)
  22. describe('toWire, confirmed against the live API', () => {
  23. it('sends page 1 for the first page (page is 1-based)', () => {
  24. expect(toWire({}, 0, 25).page).toBe(1)
  25. expect(toWire({}, 1, 25).page).toBe(2)
  26. })
  27. it('does NOT lowercase the sort field', () => {
  28. // A live query used sort:"dueDate". Lowercasing it would silently mis-sort.
  29. expect(toWire({ sortBy: 'dueDate' }, 0, 25).sort).toBe('dueDate')
  30. expect(toWire({}, 0, 25).sort).toBe('dueDate')
  31. })
  32. it('defaults to open items only', () => {
  33. const body = toWire({}, 0, 25)
  34. expect(body.completed).toBe(false)
  35. expect(body.archived).toBe(false)
  36. })
  37. it('lets the caller ask for completed items explicitly', () => {
  38. expect(toWire({ completed: true }, 0, 25).completed).toBe(true)
  39. })
  40. it('passes through the confirmed filters and omits the rest', () => {
  41. const body = toWire({ teamId: 't1', searchText: 'testimonials', isPersonal: false }, 0, 25)
  42. expect(body).toMatchObject({ teamId: 't1', searchText: 'testimonials', isPersonal: false })
  43. expect(body).not.toHaveProperty('userId')
  44. expect(body).not.toHaveProperty('dueBefore')
  45. })
  46. })
  47. describe('RawTodoSchema against the live fixture', () => {
  48. it('parses every row without stripping unknown fields', () => {
  49. for (const row of fixture) {
  50. const parsed = RawTodoSchema.parse(row)
  51. expect(parsed._id).toBeTruthy()
  52. expect(parsed).toHaveProperty('userOrdinal') // passthrough kept it
  53. }
  54. })
  55. })
  56. describe('normalizeTodo against the live fixture', () => {
  57. const todos = fixture.map((r) => normalizeTodo(RawTodoSchema.parse(r), resolve))
  58. it('maps the real field names', () => {
  59. const t = todos[0]!
  60. expect(t.id).toBe('b9954036a9e6c6430fb25939')
  61. expect(t.owner.name).toBe('Ines Carvalho')
  62. expect(t.teamName).toBe('All Hands')
  63. expect(t.dueDate).toBe('2026-08-08T04:59:59.999Z')
  64. expect(t.completed).toBe(false)
  65. expect(t.repeat).toBe('Weekly')
  66. expect(t.seriesId).toBe('c00d32d2dc9473444a1cf427')
  67. })
  68. it('strips HTML out of the description but keeps the original', () => {
  69. const t = todos[0]!
  70. expect(t.description).not.toContain('<')
  71. expect(t.description).toContain('First item')
  72. expect(t.description).toContain('Third & fourth')
  73. expect(t.descriptionHtml).toContain('<ul>')
  74. })
  75. it('never leaks a raw ObjectId as a name', () => {
  76. const t = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], userId: 'deadbeef00' }), resolve)
  77. expect(t.owner.name).toBe('unknown user')
  78. })
  79. it('treats "Never" as no recurrence', () => {
  80. const t = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], repeat: 'Never' }), resolve)
  81. expect(t.repeat).toBeNull()
  82. })
  83. })
  84. describe('stripHtml', () => {
  85. it('turns list items into lines', () => {
  86. expect(stripHtml('<ul><li>a</li><li>b</li></ul>')).toBe('- a\n- b')
  87. })
  88. it('decodes the entities Ninety actually emits', () => {
  89. expect(stripHtml('A&nbsp;&amp;&nbsp;B')).toBe('A & B')
  90. expect(stripHtml('&lt;tag&gt; &quot;q&quot; &#39;s&#39;')).toBe('<tag> "q" \'s\'')
  91. })
  92. it('drops inline styles without dropping their text', () => {
  93. expect(stripHtml('<li style="color: rgba(184, 49, 47, 1)">IMPORTANT</li>')).toBe('- IMPORTANT')
  94. })
  95. it('handles empty input', () => {
  96. expect(stripHtml('')).toBe('')
  97. })
  98. })
  99. describe('collapseSeries and the recurring-to-do double count', () => {
  100. const todos = fixture.map((r) => normalizeTodo(RawTodoSchema.parse(r), resolve))
  101. it('the fixture really does contain one task twice', () => {
  102. expect(todos).toHaveLength(2)
  103. expect(new Set(todos.map((t) => t.seriesId)).size).toBe(1)
  104. })
  105. it('collapses to one row per series', () => {
  106. expect(collapseSeries(todos)).toHaveLength(1)
  107. })
  108. it('keeps the earliest-due occurrence by default, since that is the late one', () => {
  109. expect(collapseSeries(todos)[0]!.dueDate).toBe('2026-08-08T04:59:59.999Z')
  110. })
  111. it('can keep the latest instead', () => {
  112. expect(collapseSeries(todos, 'latest')[0]!.dueDate).toBe('2026-08-15T04:59:59.999Z')
  113. })
  114. it('passes non-recurring to-dos straight through', () => {
  115. const oneOff = normalizeTodo(
  116. RawTodoSchema.parse({ ...fixture[0], _id: 'x', seriesId: null, repeat: null }),
  117. resolve,
  118. )
  119. expect(collapseSeries([...todos, oneOff])).toHaveLength(2)
  120. })
  121. it('sorts undated occurrences last rather than treating them as due now', () => {
  122. const undated = normalizeTodo(RawTodoSchema.parse({ ...fixture[0], _id: 'y', dueDate: null }), resolve)
  123. const kept = collapseSeries([undated, ...todos])[0]!
  124. expect(kept.dueDate).toBe('2026-08-08T04:59:59.999Z')
  125. })
  126. it('reports how much recurrence is inflating the count', () => {
  127. expect(seriesInflation(todos)).toEqual({ total: 2, collapsed: 1, duplicates: 1 })
  128. })
  129. })
  130. describe('TodosResource', () => {
  131. const http = (fetchImpl: typeof fetch) =>
  132. new HttpClient({ token: 't', fetchImpl, sleep: async () => {}, rps: 1000 })
  133. const respond = (body: unknown) =>
  134. new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
  135. it('harvests createdBy names for free instead of fetching them', async () => {
  136. let userCalls = 0
  137. const fetchImpl = (async (url: string | URL | Request) => {
  138. const u = String(url)
  139. if (u.includes('/users/')) {
  140. userCalls++
  141. return respond({ _id: 'x', fullName: 'Fetched Name' })
  142. }
  143. return respond(fixture)
  144. }) as unknown as typeof fetch
  145. const h = http(fetchImpl)
  146. const roster = new Roster(h)
  147. const todos = new TodosResource(h, roster)
  148. const page = await todos.query({ pageSize: 100 })
  149. expect(page.items[0]!.createdBy.name).toBe('Sample Creator')
  150. // The creator came free from the payload; only the owner needed a request.
  151. expect(userCalls).toBe(1)
  152. expect(roster.stats().noted).toBe(1)
  153. })
  154. it('filters out soft-deleted rows', async () => {
  155. const withDeleted = [{ ...fixture[0], deleted: true }, fixture[1]]
  156. const fetchImpl = (async (url: string | URL | Request) =>
  157. String(url).includes('/users/') ? respond({ _id: 'u', fullName: 'U' }) : respond(withDeleted)) as unknown as typeof fetch
  158. const h = http(fetchImpl)
  159. const out = await new TodosResource(h, new Roster(h)).list()
  160. expect(out).toHaveLength(1)
  161. expect(out[0]!.id).toBe('37c62dc317911ffcf43dc486')
  162. })
  163. it('applies the unconfirmed owner filter client-side', async () => {
  164. const mixed = [fixture[0], { ...fixture[1], userId: 'someone-else' }]
  165. const fetchImpl = (async (url: string | URL | Request) =>
  166. String(url).includes('/users/') ? respond({ _id: 'u', fullName: 'U' }) : respond(mixed)) as unknown as typeof fetch
  167. const h = http(fetchImpl)
  168. const out = await new TodosResource(h, new Roster(h)).list({ userId: '3e894807d73d5a39b03fb411' })
  169. expect(out).toHaveLength(1)
  170. })
  171. })
  172. describe('the dashboard invariant', () => {
  173. it('no rendered field ever contains a bare ObjectId', () => {
  174. const todos: Todo[] = fixture.map((r) => normalizeTodo(RawTodoSchema.parse(r), () => UNKNOWN_PERSON('x')))
  175. for (const t of todos) {
  176. expect(t.owner.name).not.toMatch(/^[0-9a-f]{24}$/)
  177. expect(t.createdBy.name).not.toMatch(/^[0-9a-f]{24}$/)
  178. }
  179. })
  180. })