Skip to content

Commit df31f4d

Browse files
committed
feat(core): implemented request body shape differencing
1 parent 6d98d58 commit df31f4d

4 files changed

Lines changed: 602 additions & 0 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import type { ApiCall } from '../model/api-call.js'
4+
5+
import { diffRequestBodies } from './body-diff.js'
6+
import type { BackendRoute, MatchResult } from './model.js'
7+
8+
const dummyLocation = {
9+
file: '/src/api.ts',
10+
line: 1,
11+
column: 1,
12+
endLine: 1,
13+
endColumn: 20,
14+
}
15+
16+
function makeApiCall(
17+
overrides: Partial<ApiCall> & Pick<ApiCall, 'id' | 'url' | 'urlKind' | 'method'>,
18+
): ApiCall {
19+
return {
20+
caller: 'axios.post',
21+
resolvedUrl: undefined,
22+
requestBody: undefined,
23+
hasErrorHandler: false,
24+
rawExpression: 'axios.post(...)',
25+
location: dummyLocation,
26+
...overrides,
27+
}
28+
}
29+
30+
function makeMatchedResult(apiCallId: string, route: BackendRoute): MatchResult {
31+
return {
32+
apiCallId,
33+
status: 'matched',
34+
route,
35+
reason: `Matched ${route.method} ${route.path}`,
36+
}
37+
}
38+
39+
const postRoute: BackendRoute = {
40+
path: '/users',
41+
method: 'POST',
42+
operationId: 'createUser',
43+
requestBody: {
44+
kind: 'resolved',
45+
fields: [
46+
{ name: 'name', type: 'string', required: true },
47+
{ name: 'age', type: 'integer', required: false },
48+
],
49+
},
50+
responseBody: undefined,
51+
}
52+
53+
const getRoute: BackendRoute = {
54+
path: '/users',
55+
method: 'GET',
56+
operationId: 'listUsers',
57+
requestBody: undefined,
58+
responseBody: undefined,
59+
}
60+
61+
const routes: BackendRoute[] = [postRoute, getRoute]
62+
63+
describe('diffRequestBodies', () => {
64+
it('returns compatible when the call body matches the route shape exactly', () => {
65+
const call = makeApiCall({
66+
id: 'call-1',
67+
method: 'POST',
68+
url: '/users',
69+
urlKind: 'string-literal',
70+
requestBody: "{ name: 'Alice', age: 30 }",
71+
})
72+
73+
const results = diffRequestBodies([call], [makeMatchedResult('call-1', postRoute)], routes)
74+
75+
expect(results).toHaveLength(1)
76+
expect(results[0]?.status).toBe('compatible')
77+
expect(results[0]?.discrepancies).toEqual([])
78+
expect(results[0]?.reason).toBeUndefined()
79+
})
80+
81+
it('flags missing required fields as discrepancies-found', () => {
82+
const call = makeApiCall({
83+
id: 'call-2',
84+
method: 'POST',
85+
url: '/users',
86+
urlKind: 'string-literal',
87+
requestBody: '{ age: 30 }',
88+
})
89+
90+
const results = diffRequestBodies([call], [makeMatchedResult('call-2', postRoute)], routes)
91+
92+
expect(results[0]?.status).toBe('discrepancies-found')
93+
expect(results[0]?.discrepancies).toEqual([
94+
{
95+
kind: 'missing-required-field',
96+
field: 'name',
97+
expected: 'string',
98+
actual: undefined,
99+
},
100+
])
101+
})
102+
103+
it('flags extra fields not in the schema as unexpected-field', () => {
104+
const call = makeApiCall({
105+
id: 'call-3',
106+
method: 'POST',
107+
url: '/users',
108+
urlKind: 'string-literal',
109+
requestBody: "{ name: 'Alice', extra: true }",
110+
})
111+
112+
const results = diffRequestBodies([call], [makeMatchedResult('call-3', postRoute)], routes)
113+
114+
expect(results[0]?.status).toBe('discrepancies-found')
115+
expect(results[0]?.discrepancies).toContainEqual({
116+
kind: 'unexpected-field',
117+
field: 'extra',
118+
expected: undefined,
119+
actual: 'boolean',
120+
})
121+
})
122+
123+
it('flags type mismatches for literal field values', () => {
124+
const call = makeApiCall({
125+
id: 'call-4',
126+
method: 'POST',
127+
url: '/users',
128+
urlKind: 'string-literal',
129+
requestBody: "{ name: 'Alice', age: 'thirty' }",
130+
})
131+
132+
const results = diffRequestBodies([call], [makeMatchedResult('call-4', postRoute)], routes)
133+
134+
expect(results[0]?.status).toBe('discrepancies-found')
135+
expect(results[0]?.discrepancies).toContainEqual({
136+
kind: 'type-mismatch',
137+
field: 'age',
138+
expected: 'integer',
139+
actual: 'string',
140+
})
141+
})
142+
143+
it('skips type comparison for non-literal field values without flagging them', () => {
144+
const call = makeApiCall({
145+
id: 'call-5',
146+
method: 'POST',
147+
url: '/users',
148+
urlKind: 'string-literal',
149+
requestBody: '{ name: userName }',
150+
})
151+
152+
const results = diffRequestBodies([call], [makeMatchedResult('call-5', postRoute)], routes)
153+
154+
expect(results[0]?.status).toBe('compatible')
155+
expect(results[0]?.discrepancies).toEqual([])
156+
})
157+
158+
it('returns not-diffable when the call request body is undefined', () => {
159+
const call = makeApiCall({
160+
id: 'call-6',
161+
method: 'POST',
162+
url: '/users',
163+
urlKind: 'string-literal',
164+
requestBody: undefined,
165+
})
166+
167+
const results = diffRequestBodies([call], [makeMatchedResult('call-6', postRoute)], routes)
168+
169+
expect(results[0]?.status).toBe('not-diffable')
170+
expect(results[0]?.reason).toBe('Request body is not statically resolvable from source')
171+
expect(results[0]?.discrepancies).toEqual([])
172+
})
173+
174+
it('returns not-diffable when the route body shape is unresolvable', () => {
175+
const unresolvableRoute: BackendRoute = {
176+
...postRoute,
177+
requestBody: {
178+
kind: 'unresolvable',
179+
reason: 'schema uses $ref or unsupported JSON Schema combinators',
180+
},
181+
}
182+
183+
const call = makeApiCall({
184+
id: 'call-7',
185+
method: 'POST',
186+
url: '/users',
187+
urlKind: 'string-literal',
188+
requestBody: "{ name: 'Alice' }",
189+
})
190+
191+
const results = diffRequestBodies(
192+
[call],
193+
[makeMatchedResult('call-7', unresolvableRoute)],
194+
routes,
195+
)
196+
197+
expect(results[0]?.status).toBe('not-diffable')
198+
expect(results[0]?.reason).toBe('schema uses $ref or unsupported JSON Schema combinators')
199+
})
200+
201+
it('returns compatible when a GET route has no body and the call sends none', () => {
202+
const call = makeApiCall({
203+
id: 'call-8',
204+
method: 'GET',
205+
url: '/users',
206+
urlKind: 'string-literal',
207+
requestBody: undefined,
208+
})
209+
210+
const results = diffRequestBodies([call], [makeMatchedResult('call-8', getRoute)], routes)
211+
212+
expect(results[0]?.status).toBe('compatible')
213+
expect(results[0]?.discrepancies).toEqual([])
214+
expect(results[0]?.reason).toBeUndefined()
215+
})
216+
217+
it('flags unexpected fields when the route declares no body but the call sends one', () => {
218+
const call = makeApiCall({
219+
id: 'call-9',
220+
method: 'GET',
221+
url: '/users',
222+
urlKind: 'string-literal',
223+
requestBody: '{ foo: 1 }',
224+
})
225+
226+
const results = diffRequestBodies([call], [makeMatchedResult('call-9', getRoute)], routes)
227+
228+
expect(results[0]?.status).toBe('discrepancies-found')
229+
expect(results[0]?.discrepancies).toEqual([
230+
{
231+
kind: 'unexpected-field',
232+
field: 'foo',
233+
expected: undefined,
234+
actual: 'integer',
235+
},
236+
])
237+
})
238+
239+
it('skips unmatched and unresolvable match results', () => {
240+
const call = makeApiCall({
241+
id: 'call-10',
242+
method: 'POST',
243+
url: '/users',
244+
urlKind: 'string-literal',
245+
requestBody: "{ name: 'Alice' }",
246+
})
247+
248+
const matchResults: MatchResult[] = [
249+
{
250+
apiCallId: 'call-10',
251+
status: 'unmatched',
252+
route: undefined,
253+
reason: 'No route found for POST /users',
254+
},
255+
{
256+
apiCallId: 'call-10',
257+
status: 'unresolvable',
258+
route: undefined,
259+
reason: 'URL could not be statically resolved (identifier)',
260+
},
261+
]
262+
263+
const results = diffRequestBodies([call], matchResults, routes)
264+
265+
expect(results).toEqual([])
266+
})
267+
})

0 commit comments

Comments
 (0)