233 lines
8.6 KiB
TypeScript
233 lines
8.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
|
import {
|
|
ContractDriftError,
|
|
fieldReport,
|
|
coverageReport,
|
|
formatFields,
|
|
formatCoverage,
|
|
assertFields,
|
|
assertShape,
|
|
coverage,
|
|
expectFields,
|
|
type Parser,
|
|
} from '../src/index.js'
|
|
|
|
/** Minimal zod-shaped parser so the library stays dependency-free. */
|
|
function requireKeys(keys: string[]): Parser<Record<string, unknown>> {
|
|
return {
|
|
safeParse(data: unknown) {
|
|
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
|
|
return { success: false as const, error: { issues: [{ path: [], message: 'expected an object' }] } }
|
|
}
|
|
const obj = data as Record<string, unknown>
|
|
const issues = keys.filter(k => !(k in obj)).map(k => ({ path: [k], message: 'required' }))
|
|
return issues.length
|
|
? { success: false as const, error: { issues } }
|
|
: { success: true as const, data: obj }
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('assertShape', () => {
|
|
it('returns the parsed data when the shape holds', () => {
|
|
expect(assertShape(requireKeys(['id']), { id: 1 }, 'GET /x')).toEqual({ id: 1 })
|
|
})
|
|
|
|
it('throws naming the context and the offending path', () => {
|
|
try {
|
|
assertShape(requireKeys(['id', 'name']), { id: 1 }, 'GET /missions')
|
|
expect.unreachable('should have thrown')
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(ContractDriftError)
|
|
const err = e as ContractDriftError
|
|
expect(err.context).toBe('GET /missions')
|
|
expect(err.message).toContain('name: required')
|
|
}
|
|
})
|
|
|
|
it('catches null where a list was expected — the empty-table failure', () => {
|
|
expect(() => assertShape(requireKeys([]), null, 'GET /rows')).toThrow(ContractDriftError)
|
|
})
|
|
|
|
it('is NOT a framework HTTP error', () => {
|
|
// A framework-specific HTTP error would tie the library to one framework.
|
|
try {
|
|
assertShape(requireKeys(['a']), {}, 'ctx')
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(Error)
|
|
expect((e as { statusCode?: number }).statusCode).toBeUndefined()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('expectFields — the silently-ignored request parameter', () => {
|
|
// The motivating case: Stripe ignores an unknown `expand` path
|
|
// rather than rejecting it, so a stale expand returns a VALID invoice that
|
|
// simply lacks the field. A response-shape validator passes this.
|
|
const basilInvoice = {
|
|
id: 'in_1',
|
|
latest_invoice: { id: 'inv_1', confirmation_secret: { client_secret: 'cs_x' } },
|
|
}
|
|
const preBasilInvoice = {
|
|
id: 'in_1',
|
|
latest_invoice: { id: 'inv_1', payment_intent: { client_secret: 'pi_x' } },
|
|
}
|
|
|
|
it('detects the field that was requested but never came back', () => {
|
|
const r = expectFields(basilInvoice, ['latest_invoice.payment_intent'])
|
|
expect(r.ok).toBe(false)
|
|
expect(r.missing).toEqual(['latest_invoice.payment_intent'])
|
|
})
|
|
|
|
it('passes when the requested field is present', () => {
|
|
expect(expectFields(preBasilInvoice, ['latest_invoice.payment_intent']).ok).toBe(true)
|
|
})
|
|
|
|
it('a schema validator would NOT catch this — the object is valid', () => {
|
|
// Both shapes are perfectly well-formed invoices. Only asking "did what I
|
|
// requested come back" distinguishes them.
|
|
expect(assertShape(requireKeys(['id', 'latest_invoice']), basilInvoice, 'stripe')).toBeTruthy()
|
|
expect(expectFields(basilInvoice, ['latest_invoice.payment_intent']).ok).toBe(false)
|
|
})
|
|
|
|
it('handles a missing intermediate segment without throwing', () => {
|
|
expect(expectFields({}, ['a.b.c']).missing).toEqual(['a.b.c'])
|
|
})
|
|
|
|
it('treats an explicit null as PRESENT — absent and null are different', () => {
|
|
// null means the provider answered "no value"; undefined means it never
|
|
// acknowledged the request. Conflating them hides the bug this catches.
|
|
expect(expectFields({ a: null }, ['a']).ok).toBe(true)
|
|
})
|
|
|
|
it('reports partial results, not just a boolean', () => {
|
|
const r = expectFields(basilInvoice, [
|
|
'latest_invoice.confirmation_secret',
|
|
'latest_invoice.payment_intent',
|
|
])
|
|
expect(r.present).toEqual(['latest_invoice.confirmation_secret'])
|
|
expect(r.missing).toEqual(['latest_invoice.payment_intent'])
|
|
})
|
|
})
|
|
|
|
describe('assertFields', () => {
|
|
it('surfaces the why-note so the failure explains itself', () => {
|
|
try {
|
|
assertFields({}, [{ path: 'latest_invoice.payment_intent', why: 'needed to mount Elements' }], 'stripe.subscriptions.create')
|
|
expect.unreachable('should have thrown')
|
|
} catch (e) {
|
|
expect((e as ContractDriftError).message).toContain('needed to mount Elements')
|
|
}
|
|
})
|
|
|
|
it('does not throw when everything requested came back', () => {
|
|
expect(() => assertFields({ a: 1 }, ['a'], 'ctx')).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('coverage', () => {
|
|
it('reports which items are uncovered, not just a percentage', () => {
|
|
const r = coverage(['a', 'b', 'c', 'd'], ['a', 'b'], 75)
|
|
expect(r.pct).toBe(50)
|
|
expect(r.uncovered).toEqual(['c', 'd'])
|
|
expect(r.belowFloor).toBe(true)
|
|
})
|
|
|
|
it('passes at exactly the floor', () => {
|
|
expect(coverage(['a', 'b'], ['a'], 50).belowFloor).toBe(false)
|
|
})
|
|
|
|
it('an empty surface is 0%, never a flattering 100', () => {
|
|
// Nothing to check is not full coverage.
|
|
const r = coverage([], [], 0)
|
|
expect(r.pct).toBe(0)
|
|
expect(r.covered).toBe(0)
|
|
})
|
|
|
|
it('ignores checked items that are not part of the surface', () => {
|
|
expect(coverage(['a'], ['a', 'ghost'], 100).pct).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('report — schema pass and assertion fail are different verdicts', () => {
|
|
it('keeps a valid-object pass separate from a missing-field failure', () => {
|
|
// The Basil failure: a perfectly valid invoice missing the field that
|
|
// was asked for. One verdict cannot carry both facts.
|
|
const r = expectFields({ id: 'in_1', latest_invoice: {} }, [
|
|
{ path: 'latest_invoice.confirmation_secret', why: 'Elements cannot mount without it' },
|
|
'id',
|
|
])
|
|
expect(r.ok).toBe(false)
|
|
const rep = fieldReport(r, { context: 'stripe.subscriptions.create', expectations: [
|
|
{ path: 'latest_invoice.confirmation_secret', why: 'Elements cannot mount without it' },
|
|
] })
|
|
expect(overall(rep)).toBe('fail')
|
|
const text = formatFields(r, {
|
|
context: 'stripe.subscriptions.create',
|
|
expectations: [{ path: 'latest_invoice.confirmation_secret', why: 'Elements cannot mount without it' }],
|
|
evidence: 'full',
|
|
})
|
|
expect(text).toContain('latest_invoice.confirmation_secret')
|
|
expect(text).toContain('Elements cannot mount without it')
|
|
expect(text).toContain('stripe.subscriptions.create')
|
|
})
|
|
|
|
it('treats an empty surface as not-assessed, not as 0% coverage', () => {
|
|
const rep = coverageReport(coverage([], []))
|
|
expect(overall(rep)).toBe('not-assessed')
|
|
expect(formatCoverage(coverage([], []))).toContain('This is not 0% coverage')
|
|
})
|
|
|
|
it('names the uncovered endpoints, not just the percentage', () => {
|
|
const c = coverage(['/a', '/b', '/c'], ['/a'], 80)
|
|
const text = formatCoverage(c, 80)
|
|
expect(text).toContain('/b')
|
|
expect(text).toContain('/c')
|
|
expect(text).toContain('Below the ratchet floor')
|
|
})
|
|
|
|
it('every conclusion carries a citation', () => {
|
|
const r = expectFields({ id: 'x' }, ['id', 'missing_one'])
|
|
expect(validateReport(fieldReport(r))).toEqual([])
|
|
expect(validateReport(coverageReport(coverage(['/a'], ['/a'])))).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('Parser accepts a Zod 4 schema', () => {
|
|
it('accepts PropertyKey paths, including symbols', () => {
|
|
// Zod 4 types issue paths as PropertyKey[]. Narrowing to string|number
|
|
// made a Zod 4 schema unassignable, and where typecheck gates the
|
|
// pipeline that mismatch blocks a deploy.
|
|
const sym = Symbol('secret')
|
|
const schema = {
|
|
safeParse: () => ({
|
|
success: false as const,
|
|
error: { issues: [{ path: ['a', 1, sym], message: 'bad' }] },
|
|
}),
|
|
}
|
|
expect(() => assertShape(schema, {}, 'ctx')).toThrow(ContractDriftError)
|
|
try {
|
|
assertShape(schema, {}, 'ctx')
|
|
} catch (e) {
|
|
// `.join()` alone throws on a symbol; this must render, not crash.
|
|
expect((e as ContractDriftError).message).toContain('Symbol(secret)')
|
|
expect((e as ContractDriftError).message).toContain('a.1.')
|
|
}
|
|
})
|
|
|
|
it('still renders a plain string path unchanged', () => {
|
|
const schema = {
|
|
safeParse: () => ({
|
|
success: false as const,
|
|
error: { issues: [{ path: ['latest_invoice', 'id'], message: 'required' }] },
|
|
}),
|
|
}
|
|
try {
|
|
assertShape(schema, {}, 'ctx')
|
|
} catch (e) {
|
|
expect((e as ContractDriftError).message).toContain('latest_invoice.id: required')
|
|
}
|
|
})
|
|
})
|