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

capture-fixtures.ts

Language TypeScript6.2 KB165 lines

scripts/capture-fixtures.ts

  1. #!/usr/bin/env tsx
  2. /**
  3. * Response-shape probe.
  4. *
  5. * Checks the shapes this client assumes against the live API and saves the raw
  6. * JSON as test fixtures, so a change on Ninety's side shows up as a fixture
  7. * diff rather than as a wrong number.
  8. *
  9. * pnpm capture
  10. *
  11. * It answers four things:
  12. * 1. Does GET /teams return a bare array?
  13. * 2. Does a team object include membership? (If yes, roster.ts gets simple.)
  14. * 3. Does POST /todos/query return a bare array or an envelope?
  15. * 4. Is the to-do page parameter 1-based? (Sends page 1 and page 2 and diffs.)
  16. *
  17. * Read-only. It never writes to Ninety.
  18. */
  19. import { writeFile, mkdir } from 'node:fs/promises'
  20. import { join } from 'node:path'
  21. const BASE = process.env.NINETY_BASE_URL ?? 'https://api.public.ninety.io/v1'
  22. const TOKEN = process.env.NINETY_TOKEN
  23. const OUT = join(process.cwd(), 'packages/ninety-client/test/fixtures/raw')
  24. if (!TOKEN) {
  25. console.error('NINETY_TOKEN is not set. Credentials come from the shell profile;')
  26. console.error('this repo has no .env reader. See credentials.example.sh, then open a')
  27. console.error('NEW terminal: an existing one keeps the old environment.')
  28. process.exit(1)
  29. }
  30. async function call(method: string, path: string, body?: unknown): Promise<{ status: number; data: unknown }> {
  31. const res = await fetch(`${BASE}${path}`, {
  32. method,
  33. headers: {
  34. Authorization: `Bearer ${TOKEN}`,
  35. Accept: 'application/json',
  36. ...(body ? { 'Content-Type': 'application/json' } : {}),
  37. },
  38. ...(body ? { body: JSON.stringify(body) } : {}),
  39. })
  40. let data: unknown
  41. try {
  42. data = await res.json()
  43. } catch {
  44. data = await res.text().catch(() => '<unreadable>')
  45. }
  46. return { status: res.status, data }
  47. }
  48. async function save(name: string, data: unknown): Promise<void> {
  49. await writeFile(join(OUT, `${name}.json`), JSON.stringify(data, null, 2), 'utf8')
  50. }
  51. function shapeOf(v: unknown): string {
  52. if (Array.isArray(v)) return `bare array (${v.length} items)`
  53. if (v && typeof v === 'object') {
  54. const keys = Object.keys(v as object)
  55. if (keys.includes('items')) return `envelope {items, ...} (keys: ${keys.join(', ')})`
  56. return `object (keys: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', ...' : ''})`
  57. }
  58. return typeof v
  59. }
  60. const findings: string[] = []
  61. const record = (q: string, a: string) => {
  62. findings.push(`${q}\n -> ${a}`)
  63. console.log(`\n${q}\n -> ${a}`)
  64. }
  65. async function main() {
  66. await mkdir(OUT, { recursive: true })
  67. console.log(`Probing ${BASE}\nFixtures -> ${OUT}\n${'-'.repeat(60)}`)
  68. // --- 1 & 2: teams -------------------------------------------------------
  69. const teams = await call('GET', '/teams')
  70. await save('teams', teams.data)
  71. if (teams.status !== 200) {
  72. record('GET /teams', `FAILED with ${teams.status}. Check the token, the Thrive plan, and Observer role.`)
  73. console.error('\nStopping: nothing else will work until /teams does.')
  74. process.exit(1)
  75. }
  76. record('Q1. What shape does GET /teams return?', shapeOf(teams.data))
  77. const firstTeam = Array.isArray(teams.data) ? (teams.data[0] as Record<string, unknown> | undefined) : undefined
  78. if (firstTeam) {
  79. const memberKeys = ['users', 'members', 'userIds', 'teamMembers', 'memberIds'].filter((k) => k in firstTeam)
  80. record(
  81. 'Q2. Does a team include membership? (decides the roster strategy)',
  82. memberKeys.length
  83. ? `YES - keys present: ${memberKeys.join(', ')}. Roster is solved in one call; skip harvesting.`
  84. : `NO - team keys are: ${Object.keys(firstTeam).join(', ')}. Harvest owner ids from to-dos and pin roster.json.`,
  85. )
  86. }
  87. // --- 3 & 4: todos -------------------------------------------------------
  88. const p1 = await call('POST', '/todos/query', { page: 1, pageSize: 5 })
  89. await save('todos.page1', p1.data)
  90. if (p1.status !== 200) {
  91. record('POST /todos/query', `FAILED with ${p1.status}. Body: ${JSON.stringify(p1.data).slice(0, 300)}`)
  92. console.error('\nThe request body shape is a guess. Check the exported OpenAPI doc for the real filter object.')
  93. process.exit(1)
  94. }
  95. record('Q3. What shape does POST /todos/query return?', shapeOf(p1.data))
  96. const p0 = await call('POST', '/todos/query', { page: 0, pageSize: 5 })
  97. await save('todos.page0', p0.data)
  98. const p2 = await call('POST', '/todos/query', { page: 2, pageSize: 5 })
  99. await save('todos.page2', p2.data)
  100. const ids = (v: unknown): string =>
  101. JSON.stringify(
  102. (Array.isArray(v) ? v : ((v as { items?: unknown[] })?.items ?? [])).map(
  103. (i) => (i as Record<string, unknown>)?._id ?? (i as Record<string, unknown>)?.id,
  104. ),
  105. )
  106. const [i0, i1, i2] = [ids(p0.data), ids(p1.data), ids(p2.data)]
  107. record(
  108. 'Q4. Is the to-do page parameter 1-based?',
  109. i0 === i1
  110. ? `LOOKS 1-BASED or page 0 is clamped: page 0 and page 1 returned the same ids. Keep the +1 in toWire().`
  111. : i1 === i2
  112. ? `AMBIGUOUS: pages 1 and 2 match. Increase the dataset or pageSize and re-run.`
  113. : `page0=${i0.slice(0, 80)}\n page1=${i1.slice(0, 80)}\n page2=${i2.slice(0, 80)}\n Compare by hand and set toWire() accordingly.`,
  114. )
  115. const sample = (Array.isArray(p1.data) ? p1.data[0] : (p1.data as { items?: unknown[] })?.items?.[0]) as
  116. | Record<string, unknown>
  117. | undefined
  118. if (sample) {
  119. record('Q5. Actual field names on a To-Do (replace the candidate lists in types.ts)', Object.keys(sample).join(', '))
  120. }
  121. // --- report -------------------------------------------------------------
  122. const report = [
  123. '# Day 0 probe results',
  124. '',
  125. `Ran against ${BASE}`,
  126. '',
  127. ...findings.map((f) => `- ${f.replace(/\n\s+-> /, '\n - ')}`),
  128. '',
  129. '## Next',
  130. '- Update docs/ARCHITECTURE.md with anything surprising.',
  131. '- Tighten packages/ninety-client/src/types.ts to the real field names.',
  132. '- Fix packages/ninety-client/src/resources/todos.ts toWire() if Q4 says so.',
  133. '- Sanitize fixtures before committing: they contain real names.',
  134. '',
  135. ].join('\n')
  136. await writeFile(join(process.cwd(), 'docs/day-0-results.md'), report, 'utf8')
  137. console.log(`\n${'-'.repeat(60)}\nWrote docs/day-0-results.md and raw fixtures.`)
  138. console.log('Fixtures contain real names. Sanitize before committing (see .gitignore).')
  139. }
  140. main().catch((err) => {
  141. console.error('\nProbe failed:', err)
  142. process.exit(1)
  143. })