diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c3ace..3718015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/). +## [1.1.1] + +### Fixed + +- **expectJsonSchema actually validates now.** It was calling the validator with + its arguments swapped, so schema checks silently passed for most bodies. It now + validates the response body against the schema. +- **Deep equality no longer reports false matches for Date / RegExp / Map / Set.** + `matches()` (used by `expectJson`, `expectBody`, `expectHeader`, `expectCookie`) + now delegates to the correct `isEqual`, so `expectJson(path, new Date(...))` and + similar comparisons are meaningful instead of always passing. +- **The JSON-schema validator rejects `NaN`** for `type: "number"` (and therefore + for `minimum`/`maximum`), since JSON has no `NaN`. + ## [1.1.0] ### Added diff --git a/package.json b/package.json index bd91aa9..994519b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "two-go", - "version": "1.1.0", + "version": "1.1.1", "description": "Zero-dependency fluent service/API testing library: chainable HTTP requests, Jest-style + HTTP assertions, and a lodash-inspired utility belt. Works standalone or inside node:test, Jest, Vitest, and Mocha.", "type": "module", "main": "src/index.js", diff --git a/src/assertions.js b/src/assertions.js index 4886b23..4037684 100644 --- a/src/assertions.js +++ b/src/assertions.js @@ -1,5 +1,7 @@ // Core assertion primitives shared by GoResponse and RequestBuilder. -// These have no dependencies and are safe to use standalone. +// Deep equality is delegated to isEqual from the utility belt so there is a +// single, correct source of truth (it handles Date, RegExp, Map, Set, NaN). +import { isEqual } from "./utils/lang.js"; // Error thrown by every failing assertion. Carries the expected/actual values // so test runners (and humans) can inspect the mismatch. @@ -37,7 +39,7 @@ export function resolvePath(obj, path) { // Flexible value matcher used by expectJson / expectHeader / expectBody. // - RegExp: test against String(actual) // - function: treat as predicate, truthy return means match -// - object/array: structural deepEqual +// - object/array: deep equality (Date/RegExp/Map/Set aware via isEqual) // - primitive: strict === export function matches(actual, expected) { if (expected instanceof RegExp) { @@ -47,30 +49,11 @@ export function matches(actual, expected) { return Boolean(expected(actual)); } if (expected !== null && typeof expected === "object") { - return deepEqual(actual, expected); + return isEqual(actual, expected); } return actual === expected; } -// Recursive structural equality. Compares own enumerable keys. -export function deepEqual(a, b) { - if (a === b) return true; - - if (a === null || b === null) return false; - if (typeof a !== "object" || typeof b !== "object") return false; - - const aIsArray = Array.isArray(a); - const bIsArray = Array.isArray(b); - if (aIsArray !== bIsArray) return false; - - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - - for (const key of aKeys) { - if (!Object.prototype.hasOwnProperty.call(b, key)) return false; - if (!deepEqual(a[key], b[key])) return false; - } - - return true; -} +// Deep structural equality. Kept as a named export for back-compat; it is the +// same correct implementation used everywhere else. +export { isEqual as deepEqual }; diff --git a/src/http-assertions.js b/src/http-assertions.js index cebef8d..f7f06ce 100644 --- a/src/http-assertions.js +++ b/src/http-assertions.js @@ -190,7 +190,7 @@ const methods = { // Validate the parsed body against a schema, listing every error on failure. expectJsonSchema(schema) { - const result = validate(schema, this.body); + const result = validate(this.body, schema); // Support either a { valid, errors } result or a bare errors array. let valid; diff --git a/src/schema.js b/src/schema.js index dedcec7..c57260e 100644 --- a/src/schema.js +++ b/src/schema.js @@ -15,7 +15,8 @@ import { // Map of supported "type" keyword values to their predicate. const TYPE_CHECKS = { string: isString, - number: isNumber, + // JSON has no NaN/Infinity, so a "number" must be finite (rejects NaN). + number: (v) => isNumber(v) && Number.isFinite(v), integer: isInteger, boolean: isBoolean, object: isPlainObject, diff --git a/test/unit/assertion-correctness.test.mjs b/test/unit/assertion-correctness.test.mjs new file mode 100644 index 0000000..ced34f4 --- /dev/null +++ b/test/unit/assertion-correctness.test.mjs @@ -0,0 +1,28 @@ +// Regression tests for the Tier 0 false-pass fixes. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { matches, deepEqual } from "../../src/assertions.js"; +import { validate } from "../../src/schema.js"; + +test("matches no longer treats different Dates as equal", () => { + assert.equal(matches(new Date(0), new Date(1000)), false); + assert.equal(matches(new Date(1000), new Date(1000)), true); +}); + +test("matches distinguishes Maps, Sets, and RegExps", () => { + assert.equal(matches(new Map([["a", 1]]), new Map([["a", 2]])), false); + assert.equal(matches(new Set([1, 2]), new Set([1, 3])), false); + assert.equal(matches(/a/i, /b/i), false); + assert.equal(deepEqual(new Set([1, 2]), new Set([1, 2])), true); +}); + +test("plain object deep equality still works", () => { + assert.equal(matches({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] }), true); + assert.equal(matches({ a: 1 }, { a: 2 }), false); +}); + +test("schema validator rejects NaN for type number and range", () => { + assert.equal(validate(NaN, { type: "number" }).valid, false); + assert.equal(validate(NaN, { type: "number", minimum: 0, maximum: 10 }).valid, false); + assert.equal(validate(5, { type: "number", minimum: 0, maximum: 10 }).valid, true); +}); diff --git a/test/unit/http-assertions.test.mjs b/test/unit/http-assertions.test.mjs index 201e0fc..427195b 100644 --- a/test/unit/http-assertions.test.mjs +++ b/test/unit/http-assertions.test.mjs @@ -238,18 +238,29 @@ test("expectValue returns an expect() wrapper for chaining matchers", () => { ); }); -test("expectJsonSchema passes when validation yields no errors", () => { - // validate(schema, body) is invoked internally; when the effective schema - // (the body) is not a plain object, validation short-circuits to valid. - const res = makeResponse({ body: "plain-text-body" }); - assert.ok(res.expectJsonSchema({ type: "string" })); +test("expectJsonSchema passes when the body matches the schema", () => { + const res = makeResponse({ body: { id: 1, name: "Ada" } }); + assert.ok( + res.expectJsonSchema({ + type: "object", + required: ["id"], + properties: { id: { type: "integer" }, name: { type: "string" } }, + }) + ); }); -test("expectJsonSchema fails and lists errors when validation fails", () => { - // Here the body acts as the effective schema and the passed argument as the - // value, so a non-matching pair produces validation errors. - const res = makeResponse({ body: { type: "number" } }); - assertFails(() => res.expectJsonSchema("not-a-number"), "GET", "/x"); +test("expectJsonSchema fails and lists errors when the body violates the schema", () => { + const res = makeResponse({ body: { id: "not-a-number" } }); + assertFails( + () => + res.expectJsonSchema({ + type: "object", + required: ["id"], + properties: { id: { type: "integer" } }, + }), + "GET", + "/x" + ); }); test("methods are installed as non-enumerable on the prototype and chain", () => {