diff --git a/README.md b/README.md index 59bbd62..57448be 100644 --- a/README.md +++ b/README.md @@ -1195,6 +1195,32 @@ The published and deployed checks are release gates. They are expected to fail b published or the documentation deployment reaches production. The scenario catalog is stored in `evals/claude-code-name-lookup.json` so the same prompt variants remain visible and reviewable. +### Testing + +```bash +npm test # Unit and behavioral tests with a mocked API (no credentials) +npm run check # Typecheck, build, tests and the public artifact guard +DM_API_URL=https://api-staging.v2.dealmachine.com/v1 DM_API_KEY= npm run smoke:live +``` + +`npm test` never calls an API. `smoke:live` builds the CLI and runs the real `dm` binary against +the API named by `DM_API_URL`, authenticated with `DM_API_KEY`. Both are required; the key is never +printed. Each Command runs with a temporary home directory, so the smoke never reads or changes +your own `dm login` credentials. It runs only read-only, credit-free Commands: + +| Command | Checks | +| ----------------------------------------------------------- | ---------------------------------------------------------- | +| `dm --version` | Prints the package version without a network request | +| `dm account --json` | Organization, user auth type and plan are present | +| `dm filters --source-type properties --per-page 5 --json` | Filters have IDs, names, operators and pagination | +| `dm fields --source-type properties --per-page 5 --json` | Fields have IDs, names and pagination | +| `dm properties search --body --estimate-cost --json` | Returns an estimate with no records and no charged credits | + +Requests pass through a local relay that refuses anything outside those read-only Endpoints +(property search must carry `estimate_cost: true`) and confirms each request sends +`X-DealMachine-Source: cli` and the CLI `User-Agent`. The smoke prints one `PASS` or `FAIL` line per +Command and exits 0 only when every Command passes. + ### Standalone Binary The compiled `dist/index.js` includes a `#!/usr/bin/env node` shebang and is declared in `package.json` under `bin.dm`. When installed globally via npm, it becomes available as `dm` on the PATH. diff --git a/docs/development.md b/docs/development.md index 02bc7c6..64b1df2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -13,7 +13,7 @@ npm run dev The [Command reference](commands.md) describes CLI usage and JSON output. Tests under `tests/` exercise request behavior and output. `test:package` installs both npm archives into an empty temporary project and checks the executable, module import and project-local Claude Code Playbook installation. It does not write to your personal agent setup or call the API. -For local API work, start the API in its owning checkout, then use `DM_API_URL=http://localhost:3001/v1 npm start -- account`. Supply your development API key through the approved local environment or `dm login`; never commit it. Normal local build and tests need no credentials. Production Commands can read or mutate live data and consume credits, so choose an environment deliberately. +For local API work, start the API in its owning checkout, then use `DM_API_URL=http://localhost:3001/v1 npm start -- account`. Supply your development API key through the approved local environment or `dm login`; never commit it. Normal local build and tests need no credentials. `npm run smoke:live` runs the built binary against a real API with `DM_API_URL` and `DM_API_KEY`, using only read-only, credit-free Commands and a temporary home directory; see the README's Testing section. Production Commands can read or mutate live data and consume credits, so choose an environment deliberately. The public agent plugin manifests and `skills/dealmachine` are retained in this repository. The hosted MCP server is a separate service. This extraction does not make MCP implementation or docs-site deployment part of CLI publication. diff --git a/package.json b/package.json index be584be..f51fa2a 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "eval:cold-start:published": "node scripts/eval-cold-start.mjs published", "eval:cold-start:deployed": "node scripts/eval-cold-start.mjs deployed", "eval:cold-start:all": "npm run build && node scripts/eval-cold-start.mjs all", + "smoke:live": "npm run build && node scripts/smoke-live.mjs", "prepublishOnly": "npm run check", "typecheck": "tsc --noEmit --composite false --incremental false", "check": "npm run typecheck && npm run build && npm test && npm run check:artifact", diff --git a/scripts/smoke-live-lib.mjs b/scripts/smoke-live-lib.mjs new file mode 100644 index 0000000..7db0f7d --- /dev/null +++ b/scripts/smoke-live-lib.mjs @@ -0,0 +1,172 @@ +/** + * Helpers for the live CLI smoke (scripts/smoke-live.mjs). + * + * Kept separate so the credential seeding, request allowlist, redaction and + * response shape checks can be unit tested without contacting an API. + */ + +import { createCipheriv, createHash, randomBytes } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +// These mirror the encrypted-file credential store in src/lib/config.ts. The +// unit test reads a seeded home through the real readConfig, so a format +// change there fails the test instead of silently breaking the smoke. +const FALLBACK_ENCRYPTION_CONTEXT = 'dealmachine-cli-api-key-v1'; +const AES_ALGORITHM = 'aes-256-gcm'; +const AES_IV_BYTES = 12; + +/** + * Write a CLI config under `homeDir` that uses the encrypted-file credential + * store. This never calls the macOS Keychain or Windows DPAPI, so the smoke + * cannot read or overwrite a developer's stored key. + */ +export function seedIsolatedConfig(homeDir, apiKey, identity = {}) { + const configDir = path.join(homeDir, '.dealmachine'); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + + const username = identity.username ?? safeUsername(); + const hostname = identity.hostname ?? os.hostname(); + const key = createHash('sha256') + .update([FALLBACK_ENCRYPTION_CONTEXT, username, hostname, configDir].join('\0')) + .digest(); + const iv = randomBytes(AES_IV_BYTES); + const cipher = createCipheriv(AES_ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(apiKey, 'utf-8'), cipher.final()]); + const payload = Buffer.concat([iv, encrypted, cipher.getAuthTag()]).toString('base64'); + + const credentialFile = path.join(configDir, 'api-key.enc'); + fs.writeFileSync(credentialFile, payload, { mode: 0o600, encoding: 'utf-8' }); + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify( + { + configVersion: 2, + keyId: 'live-smoke', + organizationId: 0, + organizationName: 'Live smoke', + organizationSlug: '', + credentialStore: 'encrypted-file', + credentialFile, + }, + null, + 2 + ), + { mode: 0o600, encoding: 'utf-8' } + ); + return configDir; +} + +function safeUsername() { + try { + return os.userInfo().username; + } catch { + return 'unknown-user'; + } +} + +/** Replace every occurrence of each secret with a fixed marker. */ +export function redact(text, secrets) { + let output = String(text ?? ''); + for (const secret of secrets) { + if (secret && secret.length >= 4) output = output.split(secret).join('[redacted]'); + } + return output; +} + +/** + * Requests the smoke may forward to the live API. Anything else is refused by + * the local relay before it leaves the machine. + */ +export function checkAllowedRequest(method, pathWithQuery, body) { + const pathname = new URL(pathWithQuery, 'http://relay.local').pathname; + if (method === 'GET' && ['/account', '/fields', '/filters'].includes(pathname)) { + return { allowed: true }; + } + if (method === 'POST' && pathname === '/properties/search') { + if (body && typeof body === 'object' && body.estimate_cost === true) { + return { allowed: true }; + } + return { allowed: false, reason: 'property search without estimate_cost=true' }; + } + return { allowed: false, reason: `${method} ${pathname} is not on the read-only allowlist` }; +} + +/** Parse JSON from CLI stdout. Throws with a short reason. */ +export function parseJsonOutput(stdout) { + try { + return JSON.parse(stdout); + } catch { + throw new Error('stdout was not valid JSON'); + } +} + +function fail(reason) { + throw new Error(reason); +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function checkPagination(pagination) { + if (!isObject(pagination)) fail('missing pagination object'); + for (const key of ['page', 'per_page', 'total_results', 'total_pages']) { + if (typeof pagination[key] !== 'number') fail(`pagination.${key} is not a number`); + } +} + +/** Shape checks return a short success reason or throw with the failure. */ +export function assertAccountShape(json) { + const data = isObject(json) ? json.data : undefined; + if (!isObject(data)) fail('missing data object'); + if (!isObject(data.organization)) fail('missing data.organization'); + if (typeof data.organization.id !== 'number') fail('data.organization.id is not a number'); + if (typeof data.organization.name !== 'string') fail('data.organization.name is not a string'); + if (!isObject(data.user) || typeof data.user.authType !== 'string') { + fail('missing data.user.authType'); + } + if (!isObject(data.plan)) fail('missing data.plan'); + return `organization ${data.organization.id}, auth ${data.user.authType}`; +} + +function assertCatalogShape(json, idKey, extraCheck) { + if (!isObject(json) || !Array.isArray(json.data)) fail('missing data array'); + if (json.data.length === 0) fail('data array is empty'); + for (const item of json.data) { + if (!isObject(item) || typeof item[idKey] !== 'string') fail(`item without string ${idKey}`); + if (typeof item.name !== 'string') fail(`${item[idKey]} has no name`); + extraCheck?.(item); + } + checkPagination(json.pagination); + return `${json.data.length} of ${json.pagination.total_results} returned`; +} + +export function assertFiltersShape(json) { + return assertCatalogShape(json, 'filter_id', (item) => { + if (!Array.isArray(item.allowed_operators)) { + fail(`${item.filter_id} has no allowed_operators array`); + } + }); +} + +export function assertFieldsShape(json) { + return assertCatalogShape(json, 'field_id'); +} + +export function assertEstimateShape(json) { + if (!isObject(json)) fail('response is not an object'); + if ('data' in json || 'credits' in json) fail('response contains records or charged credits'); + const estimate = json.estimated_credits; + if (!isObject(estimate)) fail('missing estimated_credits'); + if (typeof estimate.this_page !== 'number') fail('estimated_credits.this_page is not a number'); + if (typeof estimate.total_all_pages !== 'number') { + fail('estimated_credits.total_all_pages is not a number'); + } + if (!isObject(json.totals) || typeof json.totals.properties !== 'number') { + fail('missing totals.properties'); + } + checkPagination(json.pagination); + return `${json.totals.properties} properties, ${estimate.this_page} credits estimated for page 1`; +} diff --git a/scripts/smoke-live.mjs b/scripts/smoke-live.mjs new file mode 100644 index 0000000..b5aa6be --- /dev/null +++ b/scripts/smoke-live.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node +/** + * Live CLI smoke: runs the built `dm` binary against a real API with a short + * sequence of read-only, credit-free Commands. + * + * DM_API_URL=https://api-staging.v2.dealmachine.com/v1 DM_API_KEY=... npm run smoke:live + * + * The CLI runs in a child process with a temporary HOME, so it never reads or + * writes a developer's credentials. Its requests go through a local relay that + * records headers and refuses anything outside a read-only allowlist before it + * reaches the API. The key is never printed. + */ + +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + assertAccountShape, + assertEstimateShape, + assertFieldsShape, + assertFiltersShape, + checkAllowedRequest, + parseJsonOutput, + redact, + seedIsolatedConfig, +} from './smoke-live-lib.mjs'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const entrypoint = path.join(repoRoot, 'dist', 'index.js'); +const packageVersion = JSON.parse( + fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf-8') +).version; +const COMMAND_TIMEOUT_MS = 60_000; +const UPSTREAM_TIMEOUT_MS = 45_000; + +const apiUrl = process.env.DM_API_URL?.trim(); +const apiKey = process.env.DM_API_KEY?.trim(); +const missing = [!apiUrl && 'DM_API_URL', !apiKey && 'DM_API_KEY'].filter(Boolean); +if (missing.length > 0) { + console.error(`smoke:live needs ${missing.join(' and ')}.`); + console.error( + 'Example: DM_API_URL=https://api-staging.v2.dealmachine.com/v1 DM_API_KEY= npm run smoke:live' + ); + process.exit(1); +} + +let target; +try { + target = new URL(apiUrl); + if (!['http:', 'https:'].includes(target.protocol)) throw new Error('bad protocol'); +} catch { + console.error('DM_API_URL must be an http or https URL, for example https://api-staging.v2.dealmachine.com/v1'); + process.exit(1); +} +const targetBase = target.toString().replace(/\/+$/, ''); + +if (!fs.existsSync(entrypoint)) { + console.error('dist/index.js is missing. Run `npm run build` first or use `npm run smoke:live`.'); + process.exit(1); +} + +const secrets = [apiKey, apiKey.slice(0, 20)]; +const say = (line) => console.log(redact(line, secrets)); + +// --------------------------------------------------------------------------- +// Local relay: records each CLI request and forwards only allowlisted ones. +// --------------------------------------------------------------------------- + +const requests = []; + +const relay = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const rawBody = Buffer.concat(chunks); + let body; + if (rawBody.length > 0) { + try { + body = JSON.parse(rawBody.toString('utf-8')); + } catch { + body = undefined; + } + } + + const record = { + method: req.method, + path: req.url, + source: req.headers['x-dealmachine-source'], + userAgent: req.headers['user-agent'], + hasAuthorization: typeof req.headers.authorization === 'string', + refused: undefined, + status: undefined, + }; + requests.push(record); + + const verdict = checkAllowedRequest(req.method, req.url, body); + if (!verdict.allowed) { + record.refused = verdict.reason; + record.status = 403; + res.writeHead(403, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'smoke_refused', message: `Live smoke refused ${verdict.reason}` } })); + return; + } + + const headers = {}; + for (const name of ['authorization', 'content-type', 'user-agent', 'x-dealmachine-source', 'accept']) { + if (req.headers[name]) headers[name] = req.headers[name]; + } + + try { + const upstream = await fetch(`${targetBase}${req.url}`, { + method: req.method, + headers, + body: rawBody.length > 0 ? rawBody : undefined, + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + const responseBody = Buffer.from(await upstream.arrayBuffer()); + record.status = upstream.status; + res.writeHead(upstream.status, { + 'content-type': upstream.headers.get('content-type') || 'application/json', + }); + res.end(responseBody); + } catch (error) { + record.status = 502; + record.refused = `upstream unreachable (${error instanceof Error ? error.name : 'error'})`; + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: { code: 'smoke_upstream', message: 'Live smoke could not reach the API' } })); + } +}); + +await new Promise((resolve) => relay.listen(0, '127.0.0.1', resolve)); +const relayUrl = `http://127.0.0.1:${relay.address().port}`; + +// --------------------------------------------------------------------------- +// Isolated CLI environment +// --------------------------------------------------------------------------- + +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'dm-live-smoke-')); +seedIsolatedConfig(tempHome, apiKey); + +const childEnv = { + PATH: process.env.PATH ?? '', + HOME: tempHome, + USERPROFILE: tempHome, + TMPDIR: process.env.TMPDIR ?? os.tmpdir(), + ...(process.env.SystemRoot && { SystemRoot: process.env.SystemRoot }), + ...(process.env.TEMP && { TEMP: process.env.TEMP }), + ...(process.env.TMP && { TMP: process.env.TMP }), + DM_API_URL: relayUrl, + DM_QUIET: '1', + CI: 'true', + NO_COLOR: '1', + FORCE_COLOR: '0', +}; + +function runCli(args) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [entrypoint, ...args], { + cwd: tempHome, + env: childEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + child.stdout.on('data', (chunk) => (stdout += chunk)); + child.stderr.on('data', (chunk) => (stderr += chunk)); + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, COMMAND_TIMEOUT_MS); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr, timedOut }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +const estimateBody = JSON.stringify({ + locations: [{ type: 'zip_code', code: '78704' }], + pagination: { page: 1, per_page: 1 }, +}); + +const steps = [ + { + name: 'dm --version', + args: ['--version'], + expectRequests: [], + check: ({ stdout }) => { + const version = stdout.trim(); + if (version !== packageVersion) throw new Error(`printed "${version}", expected ${packageVersion}`); + return `version ${version}, no network request`; + }, + }, + { + name: 'dm account --json', + args: ['account', '--json'], + expectRequests: [['GET', '/account']], + check: ({ stdout }) => assertAccountShape(parseJsonOutput(stdout)), + }, + { + name: 'dm filters --source-type properties --per-page 5 --json', + args: ['filters', '--source-type', 'properties', '--per-page', '5', '--json'], + expectRequests: [['GET', '/filters']], + check: ({ stdout }) => assertFiltersShape(parseJsonOutput(stdout)), + }, + { + name: 'dm fields --source-type properties --per-page 5 --json', + args: ['fields', '--source-type', 'properties', '--per-page', '5', '--json'], + expectRequests: [['GET', '/fields']], + check: ({ stdout }) => assertFieldsShape(parseJsonOutput(stdout)), + }, + { + name: 'dm properties search --estimate-cost --json (ZIP 78704)', + args: ['properties', 'search', '--body', estimateBody, '--estimate-cost', '--json'], + expectRequests: [['POST', '/properties/search']], + check: ({ stdout }) => assertEstimateShape(parseJsonOutput(stdout)), + }, +]; + +function checkRequests(seen, expected) { + const refused = seen.find((request) => request.refused); + if (refused) throw new Error(`relay ${refused.status}: ${refused.refused}`); + const summary = seen.map((r) => `${r.method} ${new URL(r.path, relayUrl).pathname}`); + const wanted = expected.map(([method, pathname]) => `${method} ${pathname}`); + if (summary.join(',') !== wanted.join(',')) { + throw new Error(`requests were [${summary.join(', ')}], expected [${wanted.join(', ')}]`); + } + for (const request of seen) { + if (request.source !== 'cli') { + throw new Error(`x-dealmachine-source was ${JSON.stringify(request.source ?? null)}, expected "cli"`); + } + if (!String(request.userAgent ?? '').startsWith(`dm-cli/${packageVersion}`)) { + throw new Error(`user-agent was ${JSON.stringify(request.userAgent ?? null)}`); + } + if (!request.hasAuthorization) throw new Error('request had no Authorization header'); + if (request.status !== 200) throw new Error(`API returned ${request.status}`); + } +} + +function firstLine(text) { + return String(text).trim().split('\n').find(Boolean)?.slice(0, 200) ?? ''; +} + +say(`Live CLI smoke: dm ${packageVersion} against ${targetBase}`); +let failures = 0; + +try { + for (const step of steps) { + const before = requests.length; + const result = await runCli(step.args); + const seen = requests.slice(before); + let reason; + let passed = false; + try { + if (result.timedOut) throw new Error(`timed out after ${COMMAND_TIMEOUT_MS / 1000}s`); + checkRequests(seen, step.expectRequests); + if (result.code !== 0) { + throw new Error(`exit ${result.code}: ${firstLine(result.stderr) || firstLine(result.stdout)}`); + } + reason = step.check(result); + passed = true; + } catch (error) { + reason = error instanceof Error ? error.message : String(error); + } + if (!passed) failures += 1; + say(`${passed ? 'PASS' : 'FAIL'} ${step.name}: ${reason}`); + } +} finally { + relay.close(); + fs.rmSync(tempHome, { recursive: true, force: true }); +} + +const headerNote = requests.length > 0 && requests.every((r) => r.source === 'cli') + ? `; all ${requests.length} API requests carried x-dealmachine-source: cli` + : ''; +say( + failures === 0 + ? `Live smoke passed: ${steps.length} of ${steps.length} Commands${headerNote}.` + : `Live smoke failed: ${failures} of ${steps.length} Commands failed.` +); +process.exit(failures === 0 ? 0 : 1); diff --git a/tests/scripts/smokeLive.test.ts b/tests/scripts/smokeLive.test.ts new file mode 100644 index 0000000..c5758f5 --- /dev/null +++ b/tests/scripts/smokeLive.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as nodeOs from 'node:os'; +import * as path from 'node:path'; + +const { tempHome } = vi.hoisted(() => { + const { mkdtempSync } = require('node:fs') as typeof import('node:fs'); + const { tmpdir } = require('node:os') as typeof import('node:os'); + const { join } = require('node:path') as typeof import('node:path'); + return { tempHome: mkdtempSync(join(tmpdir(), 'dm-smoke-test-')) }; +}); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => tempHome }; +}); + +// The smoke must never reach the macOS Keychain or Windows DPAPI. +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(() => { + throw new Error('credential store must not be used'); + }), +})); + +import { + assertAccountShape, + assertEstimateShape, + assertFieldsShape, + assertFiltersShape, + checkAllowedRequest, + redact, + seedIsolatedConfig, +} from '../../scripts/smoke-live-lib.mjs'; +import { readConfig } from '../../src/lib/config'; + +const pagination = { page: 1, per_page: 5, total_results: 10, total_pages: 2 }; + +afterAll(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe('live smoke helpers', () => { + it('seeds a config the real CLI reads without a system credential store', async () => { + expect(nodeOs.homedir()).toBe(tempHome); + seedIsolatedConfig(tempHome, 'dm_sk_test_example_key'); + + const config = readConfig(); + expect(config?.apiKey).toBe('dm_sk_test_example_key'); + const persisted = JSON.parse( + fs.readFileSync(path.join(tempHome, '.dealmachine', 'config.json'), 'utf-8') + ); + expect(persisted.credentialStore).toBe('encrypted-file'); + expect(persisted.apiKey).toBeUndefined(); + const { execFileSync } = await import('node:child_process'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('redacts every occurrence of each secret', () => { + expect(redact('key abc12345 and abc12345 again', ['abc12345'])).toBe( + 'key [redacted] and [redacted] again' + ); + expect(redact('unchanged', ['', undefined as unknown as string])).toBe('unchanged'); + }); + + it('allows only read-only requests', () => { + expect(checkAllowedRequest('GET', '/account', undefined).allowed).toBe(true); + expect(checkAllowedRequest('GET', '/filters?source_type=properties', undefined).allowed).toBe(true); + expect(checkAllowedRequest('GET', '/fields?per_page=5', undefined).allowed).toBe(true); + expect( + checkAllowedRequest('POST', '/properties/search', { estimate_cost: true }).allowed + ).toBe(true); + expect(checkAllowedRequest('POST', '/properties/search', {}).allowed).toBe(false); + expect( + checkAllowedRequest('POST', '/properties/search', { estimate_cost: 'true' }).allowed + ).toBe(false); + expect(checkAllowedRequest('DELETE', '/account', undefined).allowed).toBe(false); + expect(checkAllowedRequest('POST', '/lists', { name: 'x' }).allowed).toBe(false); + }); + + it('checks account, catalog and estimate shapes', () => { + expect( + assertAccountShape({ + data: { + organization: { id: 1, name: 'Org' }, + user: { id: null, authType: 'api_key' }, + plan: { name: 'Plan' }, + }, + }) + ).toContain('organization 1'); + expect(() => assertAccountShape({ data: { organization: { id: '1' } } })).toThrow( + 'data.organization.id' + ); + + expect( + assertFiltersShape({ + data: [{ filter_id: 'f1', name: 'Filter', allowed_operators: ['eq'] }], + pagination, + }) + ).toBe('1 of 10 returned'); + expect(() => + assertFiltersShape({ data: [{ filter_id: 'f1', name: 'Filter' }], pagination }) + ).toThrow('allowed_operators'); + expect(() => assertFieldsShape({ data: [], pagination })).toThrow('empty'); + + const estimate = { + totals: { properties: 12, people: 20 }, + pagination, + estimated_credits: { this_page: 3, total_all_pages: 40 }, + }; + expect(assertEstimateShape(estimate)).toContain('12 properties'); + expect(() => assertEstimateShape({ ...estimate, data: [], credits: { used: 1 } })).toThrow( + 'charged credits' + ); + }); +});