capture-fixtures.ts
Language TypeScript6.2 KB165 lines
#!/usr/bin/env tsx/*** Response-shape probe.** Checks the shapes this client assumes against the live API and saves the raw* JSON as test fixtures, so a change on Ninety's side shows up as a fixture* diff rather than as a wrong number.** pnpm capture** It answers four things:* 1. Does GET /teams return a bare array?* 2. Does a team object include membership? (If yes, roster.ts gets simple.)* 3. Does POST /todos/query return a bare array or an envelope?* 4. Is the to-do page parameter 1-based? (Sends page 1 and page 2 and diffs.)** Read-only. It never writes to Ninety.*/import { writeFile, mkdir } from 'node:fs/promises'import { join } from 'node:path'const BASE = process.env.NINETY_BASE_URL ?? 'https://api.public.ninety.io/v1'const TOKEN = process.env.NINETY_TOKENconst OUT = join(process.cwd(), 'packages/ninety-client/test/fixtures/raw')if (!TOKEN) {console.error('NINETY_TOKEN is not set. Credentials come from the shell profile;')console.error('this repo has no .env reader. See credentials.example.sh, then open a')console.error('NEW terminal: an existing one keeps the old environment.')process.exit(1)}async function call(method: string, path: string, body?: unknown): Promise<{ status: number; data: unknown }> {const res = await fetch(`${BASE}${path}`, {method,headers: {Authorization: `Bearer ${TOKEN}`,Accept: 'application/json',...(body ? { 'Content-Type': 'application/json' } : {}),},...(body ? { body: JSON.stringify(body) } : {}),})let data: unknowntry {data = await res.json()} catch {data = await res.text().catch(() => '<unreadable>')}return { status: res.status, data }}async function save(name: string, data: unknown): Promise<void> {await writeFile(join(OUT, `${name}.json`), JSON.stringify(data, null, 2), 'utf8')}function shapeOf(v: unknown): string {if (Array.isArray(v)) return `bare array (${v.length} items)`if (v && typeof v === 'object') {const keys = Object.keys(v as object)if (keys.includes('items')) return `envelope {items, ...} (keys: ${keys.join(', ')})`return `object (keys: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', ...' : ''})`}return typeof v}const findings: string[] = []const record = (q: string, a: string) => {findings.push(`${q}\n -> ${a}`)console.log(`\n${q}\n -> ${a}`)}async function main() {await mkdir(OUT, { recursive: true })console.log(`Probing ${BASE}\nFixtures -> ${OUT}\n${'-'.repeat(60)}`)// --- 1 & 2: teams -------------------------------------------------------const teams = await call('GET', '/teams')await save('teams', teams.data)if (teams.status !== 200) {record('GET /teams', `FAILED with ${teams.status}. Check the token, the Thrive plan, and Observer role.`)console.error('\nStopping: nothing else will work until /teams does.')process.exit(1)}record('Q1. What shape does GET /teams return?', shapeOf(teams.data))const firstTeam = Array.isArray(teams.data) ? (teams.data[0] as Record<string, unknown> | undefined) : undefinedif (firstTeam) {const memberKeys = ['users', 'members', 'userIds', 'teamMembers', 'memberIds'].filter((k) => k in firstTeam)record('Q2. Does a team include membership? (decides the roster strategy)',memberKeys.length? `YES - keys present: ${memberKeys.join(', ')}. Roster is solved in one call; skip harvesting.`: `NO - team keys are: ${Object.keys(firstTeam).join(', ')}. Harvest owner ids from to-dos and pin roster.json.`,)}// --- 3 & 4: todos -------------------------------------------------------const p1 = await call('POST', '/todos/query', { page: 1, pageSize: 5 })await save('todos.page1', p1.data)if (p1.status !== 200) {record('POST /todos/query', `FAILED with ${p1.status}. Body: ${JSON.stringify(p1.data).slice(0, 300)}`)console.error('\nThe request body shape is a guess. Check the exported OpenAPI doc for the real filter object.')process.exit(1)}record('Q3. What shape does POST /todos/query return?', shapeOf(p1.data))const p0 = await call('POST', '/todos/query', { page: 0, pageSize: 5 })await save('todos.page0', p0.data)const p2 = await call('POST', '/todos/query', { page: 2, pageSize: 5 })await save('todos.page2', p2.data)const ids = (v: unknown): string =>JSON.stringify((Array.isArray(v) ? v : ((v as { items?: unknown[] })?.items ?? [])).map((i) => (i as Record<string, unknown>)?._id ?? (i as Record<string, unknown>)?.id,),)const [i0, i1, i2] = [ids(p0.data), ids(p1.data), ids(p2.data)]record('Q4. Is the to-do page parameter 1-based?',i0 === i1? `LOOKS 1-BASED or page 0 is clamped: page 0 and page 1 returned the same ids. Keep the +1 in toWire().`: i1 === i2? `AMBIGUOUS: pages 1 and 2 match. Increase the dataset or pageSize and re-run.`: `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.`,)const sample = (Array.isArray(p1.data) ? p1.data[0] : (p1.data as { items?: unknown[] })?.items?.[0]) as| Record<string, unknown>| undefinedif (sample) {record('Q5. Actual field names on a To-Do (replace the candidate lists in types.ts)', Object.keys(sample).join(', '))}// --- report -------------------------------------------------------------const report = ['# Day 0 probe results','',`Ran against ${BASE}`,'',...findings.map((f) => `- ${f.replace(/\n\s+-> /, '\n - ')}`),'','## Next','- Update docs/ARCHITECTURE.md with anything surprising.','- Tighten packages/ninety-client/src/types.ts to the real field names.','- Fix packages/ninety-client/src/resources/todos.ts toWire() if Q4 says so.','- Sanitize fixtures before committing: they contain real names.','',].join('\n')await writeFile(join(process.cwd(), 'docs/day-0-results.md'), report, 'utf8')console.log(`\n${'-'.repeat(60)}\nWrote docs/day-0-results.md and raw fixtures.`)console.log('Fixtures contain real names. Sanitize before committing (see .gitignore).')}main().catch((err) => {console.error('\nProbe failed:', err)process.exit(1)})