Skip to content

Commit 420e831

Browse files
kevinccbsgclaude
andauthored
fix: forward mock Content-Type to contract validator (#5)
* chore: update dependencies (openapi-mock-validator 0.2.0, twd-js 1.7.2, puppeteer 24.42.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: add image/* photo endpoint to petstore fixture * test: add failing test for Content-Type forwarding * fix: forward mock Content-Type to contract validator * test: add case-insensitivity and default tests for Content-Type * test: assert mock collector preserves responseHeaders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update version to v1.1.10 * chore(test-example-app): bump twd-js to 1.7.2 for smoke testing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(test-example-app): add binary Content-Type smoke tests - Adds /products/{id}/thumbnail endpoint (image/*) to products-3.0.json - Adds 4 mocks exercising the new Content-Type forwarding: - image/png and image/jpeg against image/* spec → should validate cleanly - application/xml against JSON-only /api/products → should warn (MISSING_SCHEMA) - No responseHeaders against image-only endpoint → should warn (defaults to JSON, no match) All four endpoints use contract mode 'warn' so CI does not fail on the intentional negative cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lockfile): regenerate package-lock.json to restore @emnapi entries CI was failing with `npm ci` errors because the previous dep-bump commit's lockfile was missing top-level @emnapi/core and @emnapi/runtime entries that are required by transitive peers of @napi-rs/wasm-runtime. Regenerating via a fresh `npm install` restores them. All 177 tests pass and `npm ci` now succeeds locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9500e0c commit 420e831

11 files changed

Lines changed: 441 additions & 194 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## <small>1.1.10 (2026-04-21)</small>
2+
3+
* fix: forward mock Content-Type to contract validator (avoids false-positive `MISSING_SCHEMA` for binary mocks)
4+
15
## <small>1.1.9 (2026-04-15)</small>
26

37
* chore: update twd-js to 1.7.1

package-lock.json

Lines changed: 180 additions & 180 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "twd-cli",
3-
"version": "1.1.9",
3+
"version": "1.1.10",
44
"description": "CLI tool for running TWD tests with Puppeteer",
55
"type": "module",
66
"main": "src/index.js",
@@ -23,9 +23,9 @@
2323
"author": "",
2424
"license": "ISC",
2525
"dependencies": {
26-
"openapi-mock-validator": "^0.1.4",
27-
"puppeteer": "^24.41.0",
28-
"twd-js": "^1.7.1"
26+
"openapi-mock-validator": "^0.2.0",
27+
"puppeteer": "^24.42.0",
28+
"twd-js": "^1.7.2"
2929
},
3030
"engines": {
3131
"node": ">=18.0.0"

src/contracts.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import fs from 'fs';
22
import path from 'path';
33
import { OpenAPIMockValidator } from 'openapi-mock-validator';
44

5+
function readContentType(responseHeaders) {
6+
if (!responseHeaders) return 'application/json';
7+
for (const [key, value] of Object.entries(responseHeaders)) {
8+
if (key.toLowerCase() === 'content-type') return value;
9+
}
10+
return 'application/json';
11+
}
12+
513
export async function loadContracts(contracts, workingDir) {
614
const loaded = [];
715

@@ -67,12 +75,13 @@ export function validateMocks(collectedMocks, contracts) {
6775
continue;
6876
}
6977

78+
const contentType = readContentType(mock.responseHeaders);
7079
const validation = contract.validator.validateResponse(
7180
pathMatch.path,
7281
mock.method,
7382
mock.status,
7483
mock.response,
75-
{ strict: contract.strict },
84+
{ strict: contract.strict, contentType },
7685
);
7786

7887
results.push({

test-example-app/contracts/products-3.0.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,29 @@
7474
}
7575
}
7676
},
77+
"/products/{productId}/thumbnail": {
78+
"get": {
79+
"operationId": "getProductThumbnail",
80+
"parameters": [
81+
{
82+
"name": "productId",
83+
"in": "path",
84+
"required": true,
85+
"schema": { "type": "string", "format": "uuid" }
86+
}
87+
],
88+
"responses": {
89+
"200": {
90+
"description": "Product thumbnail image",
91+
"content": {
92+
"image/*": {
93+
"schema": { "type": "string", "format": "binary" }
94+
}
95+
}
96+
}
97+
}
98+
}
99+
},
77100
"/settings": {
78101
"get": {
79102
"operationId": "getSettings",

test-example-app/package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test-example-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"@types/react-dom": "^19.2.3",
1919
"@vitejs/plugin-react": "^6.0.1",
2020
"babel-plugin-react-compiler": "^1.0.0",
21-
"twd-js": "^1.6.5",
21+
"twd-js": "^1.7.2",
2222
"typescript": "~6.0.2",
2323
"vite": "^8.0.3"
2424
}

test-example-app/src/App.twd.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,3 +897,46 @@ describe("Contract Validation - Events Mismatches (OpenAPI 3.1 — error mode)",
897897
});
898898
});
899899
});
900+
901+
// ── Content-Type forwarding — binary mocks vs image/* spec ───────────
902+
903+
describe("Contract Validation - Content-Type forwarding (Products API)", () => {
904+
it("should match image/png mock against image/* spec entry", async () => {
905+
twd.mockRequest("getProductThumbnailPng", {
906+
method: "GET",
907+
url: "/api/products/550e8400-e29b-41d4-a716-446655440000/thumbnail",
908+
status: 200,
909+
response: "fake-png-bytes",
910+
responseHeaders: { "Content-Type": "image/png" },
911+
});
912+
});
913+
914+
it("should match image/jpeg mock against image/* spec entry", async () => {
915+
twd.mockRequest("getProductThumbnailJpeg", {
916+
method: "GET",
917+
url: "/api/products/550e8400-e29b-41d4-a716-446655440001/thumbnail",
918+
status: 200,
919+
response: "fake-jpeg-bytes",
920+
responseHeaders: { "content-type": "image/jpeg" },
921+
});
922+
});
923+
924+
it("should warn when non-binary Content-Type has no matching spec entry", async () => {
925+
twd.mockRequest("getProductsAsXml", {
926+
method: "GET",
927+
url: "/api/products",
928+
status: 200,
929+
response: "<products></products>",
930+
responseHeaders: { "Content-Type": "application/xml" },
931+
});
932+
});
933+
934+
it("should warn when mock has no responseHeaders against image-only endpoint", async () => {
935+
twd.mockRequest("getProductThumbnailNoHeader", {
936+
method: "GET",
937+
url: "/api/products/550e8400-e29b-41d4-a716-446655440002/thumbnail",
938+
status: 200,
939+
response: "fake-bytes",
940+
});
941+
});
942+
});

tests/contracts.test.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,96 @@ describe('validateMocks with testId and occurrence', () => {
256256
expect(output.results[1].validation.valid).toBe(true);
257257
});
258258
});
259+
260+
describe('validateMocks — Content-Type forwarding', () => {
261+
let loadedContracts;
262+
263+
beforeEach(async () => {
264+
loadedContracts = await loadContracts(
265+
[{ source: './tests/fixtures/petstore-3.0.json', baseUrl: '/api' }],
266+
path.resolve(__dirname, '..'),
267+
);
268+
});
269+
270+
it('forwards Content-Type from responseHeaders so image/* endpoints match', () => {
271+
const mocks = new Map();
272+
mocks.set('getPetPhoto', {
273+
alias: 'getPetPhoto',
274+
url: '/api/v1/pets/123/photo',
275+
method: 'GET',
276+
status: 200,
277+
response: 'fake-binary-data',
278+
urlRegex: false,
279+
responseHeaders: { 'Content-Type': 'image/png' },
280+
});
281+
282+
const output = validateMocks(mocks, loadedContracts);
283+
284+
expect(output.results).toHaveLength(1);
285+
const missingSchema = output.results[0].validation.warnings.filter(
286+
w => w.type === 'MISSING_SCHEMA'
287+
);
288+
expect(missingSchema).toHaveLength(0);
289+
expect(output.results[0].validation.errors).toHaveLength(0);
290+
});
291+
292+
it('resolves Content-Type when header key is lowercase', () => {
293+
const mocks = new Map();
294+
mocks.set('getPetPhoto', {
295+
alias: 'getPetPhoto',
296+
url: '/api/v1/pets/123/photo',
297+
method: 'GET',
298+
status: 200,
299+
response: 'fake-binary-data',
300+
urlRegex: false,
301+
responseHeaders: { 'content-type': 'image/png' },
302+
});
303+
const output = validateMocks(mocks, loadedContracts);
304+
expect(output.results[0].validation.warnings.some(w => w.type === 'MISSING_SCHEMA')).toBe(false);
305+
});
306+
307+
it('resolves Content-Type when header key is upper-case', () => {
308+
const mocks = new Map();
309+
mocks.set('getPetPhoto', {
310+
alias: 'getPetPhoto',
311+
url: '/api/v1/pets/123/photo',
312+
method: 'GET',
313+
status: 200,
314+
response: 'fake-binary-data',
315+
urlRegex: false,
316+
responseHeaders: { 'CONTENT-TYPE': 'image/png' },
317+
});
318+
const output = validateMocks(mocks, loadedContracts);
319+
expect(output.results[0].validation.warnings.some(w => w.type === 'MISSING_SCHEMA')).toBe(false);
320+
});
321+
322+
it('defaults to application/json when responseHeaders is missing', () => {
323+
const mocks = new Map();
324+
mocks.set('getPets', {
325+
alias: 'getPets',
326+
url: '/api/v1/pets',
327+
method: 'GET',
328+
status: 200,
329+
response: [{ id: 1, name: 'Fido' }],
330+
urlRegex: false,
331+
});
332+
const output = validateMocks(mocks, loadedContracts);
333+
expect(output.results[0].validation.valid).toBe(true);
334+
expect(output.results[0].validation.errors).toHaveLength(0);
335+
});
336+
337+
it('defaults to application/json when responseHeaders has no Content-Type entry', () => {
338+
const mocks = new Map();
339+
mocks.set('getPets', {
340+
alias: 'getPets',
341+
url: '/api/v1/pets',
342+
method: 'GET',
343+
status: 200,
344+
response: [{ id: 1, name: 'Fido' }],
345+
urlRegex: false,
346+
responseHeaders: { 'X-Request-Id': 'abc123' },
347+
});
348+
const output = validateMocks(mocks, loadedContracts);
349+
expect(output.results[0].validation.valid).toBe(true);
350+
});
351+
});

tests/fixtures/petstore-3.0.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,28 @@
8282
}
8383
}
8484
}
85+
},
86+
"/v1/pets/{petId}/photo": {
87+
"get": {
88+
"parameters": [
89+
{
90+
"name": "petId",
91+
"in": "path",
92+
"required": true,
93+
"schema": { "type": "string" }
94+
}
95+
],
96+
"responses": {
97+
"200": {
98+
"description": "Pet photo",
99+
"content": {
100+
"image/*": {
101+
"schema": { "type": "string", "format": "binary" }
102+
}
103+
}
104+
}
105+
}
106+
}
85107
}
86108
}
87109
}

0 commit comments

Comments
 (0)