From d3532fc3528b62f0fdcc5a02d197467f9b09b3a6 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Apr 2026 18:46:15 -0700 Subject: [PATCH 1/5] feat(test): add HTTP test client for integration testing Adds a fluent HTTP test client inspired by Laravel's test helpers. TestClient provides chainable request methods (get/post/put/patch/delete) and assertion methods (assertStatus/assertSee/assertJson/etc.) for writing concise integration tests. WheelsTest gains visit() and $testClient() helpers. Co-Authored-By: Claude Opus 4.6 (1M context) --- vendor/wheels/WheelsTest.cfc | 32 ++ .../tests/specs/internal/testClientSpec.cfc | 259 +++++++++ vendor/wheels/wheelstest/TestClient.cfc | 530 ++++++++++++++++++ 3 files changed, 821 insertions(+) create mode 100644 vendor/wheels/tests/specs/internal/testClientSpec.cfc create mode 100644 vendor/wheels/wheelstest/TestClient.cfc diff --git a/vendor/wheels/WheelsTest.cfc b/vendor/wheels/WheelsTest.cfc index 0fdde369e..9b261ac67 100644 --- a/vendor/wheels/WheelsTest.cfc +++ b/vendor/wheels/WheelsTest.cfc @@ -25,4 +25,36 @@ component extends="wheels.wheelstest.system.BaseSpec" { } } + /** + * Create a TestClient and visit the given path (HTTP GET). + * Returns the TestClient for fluent assertion chaining. + * + * Usage in tests: + * visit("/users").assertOk().assertSee("John") + * + * @path URL path to visit + */ + public any function visit(required string path) { + return $testClient().get(arguments.path); + } + + /** + * Return a configured TestClient instance. + * The base URL is auto-detected from the current server port. + */ + public any function $testClient() { + return new wheels.wheelstest.TestClient(baseUrl = $getTestBaseUrl()); + } + + /** + * Auto-detect the base URL of the running test server. + */ + private string function $getTestBaseUrl() { + var port = CGI.SERVER_PORT; + if (!Len(port) || port == 0) { + port = 8080; + } + return "http://localhost:" & port; + } + } diff --git a/vendor/wheels/tests/specs/internal/testClientSpec.cfc b/vendor/wheels/tests/specs/internal/testClientSpec.cfc new file mode 100644 index 000000000..b6284e19f --- /dev/null +++ b/vendor/wheels/tests/specs/internal/testClientSpec.cfc @@ -0,0 +1,259 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("TestClient", () => { + + beforeEach(() => { + client = $testClient(); + }); + + describe("initialization", () => { + + it("initializes with default baseUrl", () => { + var c = new wheels.wheelstest.TestClient(); + expect(c).toBeInstanceOf("wheels.wheelstest.TestClient"); + }); + + it("initializes with custom baseUrl", () => { + var c = new wheels.wheelstest.TestClient(baseUrl = "http://localhost:9999"); + expect(c).toBeInstanceOf("wheels.wheelstest.TestClient"); + }); + + }); + + describe("request methods", () => { + + it("get() makes HTTP GET request", () => { + client.get("/"); + expect(client.statusCode()).toBeGT(0); + }); + + it("post() makes HTTP POST request", () => { + client.post("/"); + expect(client.statusCode()).toBeGT(0); + }); + + it("visit() is alias for get()", () => { + client.visit("/"); + expect(client.statusCode()).toBeGT(0); + }); + + }); + + describe("assertions", () => { + + it("assertStatus() passes on correct status code", () => { + client.get("/"); + client.assertStatus(client.statusCode()); + }); + + it("assertStatus() fails on wrong status code", () => { + client.get("/"); + expect(function() { + client.assertStatus(999); + }).toThrow("TestBox.AssertionFailed"); + }); + + it("assertOk() passes on 200 response", () => { + client.get("/?reload=true&password=wheels"); + client.assertOk(); + }); + + it("assertNotFound() passes on 404 response", () => { + client.get("/wheels-nonexistent-route-that-should-404"); + client.assertNotFound(); + }); + + it("assertSee() finds text in response body", () => { + client.get("/"); + var body = client.content(); + if (Len(body)) { + var snippet = Left(body, 10); + if (Len(snippet)) { + client.assertSee(snippet); + } + } + }); + + it("assertSee() fails when text is not found", () => { + client.get("/"); + expect(function() { + client.assertSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ"); + }).toThrow("TestBox.AssertionFailed"); + }); + + it("assertDontSee() confirms text is absent", () => { + client.get("/"); + client.assertDontSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ"); + }); + + it("assertDontSee() fails when text is present", () => { + client.get("/"); + var body = client.content(); + if (Len(body)) { + var snippet = Left(body, 10); + if (Len(snippet)) { + expect(function() { + client.assertDontSee(snippet); + }).toThrow("TestBox.AssertionFailed"); + } + } + }); + + it("assertJson() validates JSON response", () => { + client.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); + client.assertJson(); + }); + + it("assertJson() fails on non-JSON response", () => { + client.get("/"); + var body = client.content(); + if (Len(body) && Left(Trim(body), 1) != "{" && Left(Trim(body), 1) != "[") { + expect(function() { + client.assertJson(); + }).toThrow("TestBox.AssertionFailed"); + } + }); + + it("assertRedirect() checks 3xx status", () => { + client.get("/"); + var code = client.statusCode(); + if (code >= 300 && code < 400) { + client.assertRedirect(); + } else { + expect(function() { + client.assertRedirect(); + }).toThrow("TestBox.AssertionFailed"); + } + }); + + }); + + describe("request configuration", () => { + + it("withHeaders() adds custom headers", () => { + client.withHeaders({"X-Custom-Test": "hello"}); + client.get("/"); + expect(client.statusCode()).toBeGT(0); + }); + + it("withHeader() adds a single header", () => { + client.withHeader("X-Custom-Test", "hello"); + client.get("/"); + expect(client.statusCode()).toBeGT(0); + }); + + it("asJson() sets content type and returns client for chaining", () => { + var result = client.asJson(); + expect(result).toBeInstanceOf("wheels.wheelstest.TestClient"); + }); + + }); + + describe("response accessors", () => { + + beforeEach(() => { + client.get("/"); + }); + + it("content() returns response body as string", () => { + expect(client.content()).toBeString(); + }); + + it("statusCode() returns numeric status", () => { + expect(client.statusCode()).toBeNumeric(); + }); + + it("headers() returns response headers struct", () => { + expect(client.headers()).toBeStruct(); + }); + + it("response() returns full response struct", () => { + expect(client.response()).toBeStruct(); + }); + + }); + + describe("chaining", () => { + + it("supports fluent chaining: visit().assertOk().assertSee()", () => { + client.visit("/?reload=true&password=wheels"); + var code = client.statusCode(); + if (code == 200) { + var body = client.content(); + if (Len(body)) { + var snippet = Left(body, 5); + if (Len(snippet)) { + client.assertOk().assertSee(snippet); + } + } + } + }); + + it("supports withHeaders().get().assertStatus() chain", () => { + client.withHeader("Accept", "text/html").get("/"); + client.assertStatus(client.statusCode()); + }); + + }); + + describe("assertSeeInOrder", () => { + + it("passes when texts appear in order", () => { + client.get("/"); + var body = client.content(); + if (Len(body) > 20) { + var first = Mid(body, 1, 5); + var second = Mid(body, 10, 5); + client.assertSeeInOrder([first, second]); + } + }); + + it("fails when texts appear out of order", () => { + client.get("/"); + var body = client.content(); + if (Len(body) > 20) { + var first = Mid(body, 10, 5); + var second = Mid(body, 1, 5); + expect(function() { + client.assertSeeInOrder([first, second]); + }).toThrow("TestBox.AssertionFailed"); + } + }); + + }); + + describe("assertJsonPath", () => { + + it("resolves dot-notation paths in JSON", () => { + client.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); + var jsonData = client.json(); + if (StructKeyExists(jsonData, "totalPass")) { + client.assertJsonPath("totalPass", jsonData.totalPass); + } + }); + + }); + + describe("assertHeader", () => { + + it("passes when header exists", () => { + client.get("/"); + client.assertHeader("Content-Type"); + }); + + it("fails when header is missing", () => { + client.get("/"); + expect(function() { + client.assertHeader("X-Nonexistent-Header-For-Test"); + }).toThrow("TestBox.AssertionFailed"); + }); + + }); + + }); + + } + +} diff --git a/vendor/wheels/wheelstest/TestClient.cfc b/vendor/wheels/wheelstest/TestClient.cfc new file mode 100644 index 000000000..7e69bd100 --- /dev/null +++ b/vendor/wheels/wheelstest/TestClient.cfc @@ -0,0 +1,530 @@ +/** + * Fluent HTTP test client for Wheels integration testing. + * + * Inspired by Laravel's HTTP test client. Provides a chainable API + * for making HTTP requests and asserting on responses within test specs. + * + * Usage: + * visit("/users").assertOk().assertSee("John") + * post("/users", {firstName: "Jane"}).assertCreated() + * get("/api/users").asJson().assertJson({total: 5}) + */ +component { + + // State + variables.baseUrl = ""; + variables.lastResponse = {}; + variables.defaultHeaders = {}; + variables.cookies = {}; + variables.sendAsJson = false; + + /** + * Initialize the test client with a base URL. + * + * @baseUrl The base URL for all requests (e.g. "http://localhost:8080") + */ + public TestClient function init(string baseUrl = "http://localhost:8080") { + variables.baseUrl = arguments.baseUrl; + variables.lastResponse = {}; + variables.defaultHeaders = {}; + variables.cookies = {}; + variables.sendAsJson = false; + return this; + } + + // ─── HTTP Methods ──────────────────────────────────────────────── + + /** + * Make an HTTP GET request. + * + * @path URL path (appended to baseUrl) + * @params Query string parameters as a struct + * @headers Additional headers for this request + */ + public TestClient function get( + required string path, + struct params = {}, + struct headers = {} + ) { + $makeRequest(method = "GET", path = arguments.path, params = arguments.params, headers = arguments.headers); + return this; + } + + /** + * Make an HTTP POST request. + * + * @path URL path (appended to baseUrl) + * @body Request body as a struct + * @headers Additional headers for this request + */ + public TestClient function post( + required string path, + struct body = {}, + struct headers = {} + ) { + $makeRequest(method = "POST", path = arguments.path, body = arguments.body, headers = arguments.headers); + return this; + } + + /** + * Make an HTTP PUT request. + * + * @path URL path (appended to baseUrl) + * @body Request body as a struct + * @headers Additional headers for this request + */ + public TestClient function put( + required string path, + struct body = {}, + struct headers = {} + ) { + $makeRequest(method = "PUT", path = arguments.path, body = arguments.body, headers = arguments.headers); + return this; + } + + /** + * Make an HTTP PATCH request. + * + * @path URL path (appended to baseUrl) + * @body Request body as a struct + * @headers Additional headers for this request + */ + public TestClient function patch( + required string path, + struct body = {}, + struct headers = {} + ) { + $makeRequest(method = "PATCH", path = arguments.path, body = arguments.body, headers = arguments.headers); + return this; + } + + /** + * Make an HTTP DELETE request. + * + * @path URL path (appended to baseUrl) + * @headers Additional headers for this request + */ + public TestClient function delete( + required string path, + struct headers = {} + ) { + $makeRequest(method = "DELETE", path = arguments.path, headers = arguments.headers); + return this; + } + + /** + * Alias for get(). Reads more naturally in tests: visit("/").assertOk() + * + * @path URL path (appended to baseUrl) + */ + public TestClient function visit(required string path) { + return get(path = arguments.path); + } + + // ─── Request Configuration ─────────────────────────────────────── + + /** + * Set multiple default headers for subsequent requests. + * + * @headers Struct of header name/value pairs + */ + public TestClient function withHeaders(required struct headers) { + structAppend(variables.defaultHeaders, arguments.headers, true); + return this; + } + + /** + * Set a single default header for subsequent requests. + * + * @name Header name + * @value Header value + */ + public TestClient function withHeader(required string name, required string value) { + variables.defaultHeaders[arguments.name] = arguments.value; + return this; + } + + /** + * Set a cookie to send with subsequent requests. + * + * @name Cookie name + * @value Cookie value + */ + public TestClient function withCookie(required string name, required string value) { + variables.cookies[arguments.name] = arguments.value; + return this; + } + + /** + * Configure the client to send and accept JSON. + * Sets Content-Type and Accept headers to application/json. + */ + public TestClient function asJson() { + variables.sendAsJson = true; + variables.defaultHeaders["Content-Type"] = "application/json"; + variables.defaultHeaders["Accept"] = "application/json"; + return this; + } + + // ─── Assertions ────────────────────────────────────────────────── + + /** + * Assert the response has the given HTTP status code. + * + * @statusCode Expected HTTP status code + */ + public TestClient function assertStatus(required numeric statusCode) { + var actual = statusCode(); + if (actual != arguments.statusCode) { + $assertionError("Expected status code #arguments.statusCode# but received #actual#."); + } + return this; + } + + /** + * Assert the response has HTTP 200 OK status. + */ + public TestClient function assertOk() { + return assertStatus(200); + } + + /** + * Assert the response has HTTP 201 Created status. + */ + public TestClient function assertCreated() { + return assertStatus(201); + } + + /** + * Assert the response has HTTP 204 No Content status. + */ + public TestClient function assertNoContent() { + return assertStatus(204); + } + + /** + * Assert the response has HTTP 404 Not Found status. + */ + public TestClient function assertNotFound() { + return assertStatus(404); + } + + /** + * Assert the response is a redirect (3xx status). + * Optionally check the Location header matches a given path. + * + * @to Optional expected Location header value + */ + public TestClient function assertRedirect(string to = "") { + var code = statusCode(); + if (code < 300 || code >= 400) { + $assertionError("Expected redirect status (3xx) but received #code#."); + } + if (Len(arguments.to)) { + var hdrs = headers(); + var loc = ""; + if (StructKeyExists(hdrs, "Location")) { + loc = hdrs.Location; + } + if (loc != arguments.to && !FindNoCase(arguments.to, loc)) { + $assertionError("Expected redirect to '#arguments.to#' but Location header is '#loc#'."); + } + } + return this; + } + + /** + * Assert the response body contains the given text. + * + * @text Text to search for in the response body + */ + public TestClient function assertSee(required string text) { + var body = content(); + if (!FindNoCase(arguments.text, body)) { + $assertionError("Expected to see '#arguments.text#' in response body but it was not found."); + } + return this; + } + + /** + * Assert the response body does NOT contain the given text. + * + * @text Text that should be absent from the response body + */ + public TestClient function assertDontSee(required string text) { + var body = content(); + if (FindNoCase(arguments.text, body)) { + $assertionError("Expected NOT to see '#arguments.text#' in response body but it was found."); + } + return this; + } + + /** + * Assert the given texts appear in the response body in order. + * + * @texts Array of strings that should appear in order + */ + public TestClient function assertSeeInOrder(required array texts) { + var body = content(); + var lastPos = 0; + for (var i = 1; i <= ArrayLen(arguments.texts); i++) { + var text = arguments.texts[i]; + var pos = FindNoCase(text, body, lastPos + 1); + if (pos == 0) { + $assertionError("Expected to see '#text#' in order in response body (item #i# of #ArrayLen(arguments.texts)#) but it was not found after position #lastPos#."); + } + lastPos = pos; + } + return this; + } + + /** + * Assert the response is valid JSON. Optionally assert it contains + * a subset of the expected key/value pairs. + * + * @expected Optional struct of expected key/value pairs to match + */ + public TestClient function assertJson(struct expected = {}) { + var body = content(); + var parsed = {}; + try { + parsed = DeserializeJSON(body); + } catch (any e) { + $assertionError("Expected response to be valid JSON but could not parse it. Body: #Left(body, 200)#"); + } + if (!StructIsEmpty(arguments.expected)) { + for (var key in arguments.expected) { + if (!StructKeyExists(parsed, key)) { + $assertionError("Expected JSON response to contain key '#key#' but it was not found."); + } + if (parsed[key] != arguments.expected[key]) { + $assertionError("Expected JSON key '#key#' to be '#arguments.expected[key]#' but got '#parsed[key]#'."); + } + } + } + return this; + } + + /** + * Assert a value at a dot-notation path in the JSON response. + * Array indices are 0-based in the path but 1-based internally (CFML arrays). + * + * Example: assertJsonPath("users.0.name", "John") + * + * @path Dot-notation path into the JSON structure + * @expectedValue Expected value at that path + */ + public TestClient function assertJsonPath(required string path, any expectedValue) { + var body = content(); + var parsed = {}; + try { + parsed = DeserializeJSON(body); + } catch (any e) { + $assertionError("Expected response to be valid JSON for path assertion. Body: #Left(body, 200)#"); + } + var segments = ListToArray(arguments.path, "."); + var current = parsed; + for (var i = 1; i <= ArrayLen(segments); i++) { + var segment = segments[i]; + if (IsNumeric(segment) && IsArray(current)) { + // 0-based input -> 1-based CFML + var idx = Int(segment) + 1; + if (idx < 1 || idx > ArrayLen(current)) { + $assertionError("JSON path '#arguments.path#' failed: array index #segment# (0-based) is out of bounds (array length: #ArrayLen(current)#)."); + } + current = current[idx]; + } else if (IsStruct(current) && StructKeyExists(current, segment)) { + current = current[segment]; + } else { + $assertionError("JSON path '#arguments.path#' failed: key '#segment#' not found at this level."); + } + } + if (current != arguments.expectedValue) { + $assertionError("Expected JSON path '#arguments.path#' to be '#arguments.expectedValue#' but got '#current#'."); + } + return this; + } + + /** + * Assert a response header exists and optionally matches a value. + * + * @name Header name to check + * @value Optional expected header value + */ + public TestClient function assertHeader(required string name, string value = "") { + var hdrs = headers(); + if (!StructKeyExists(hdrs, arguments.name)) { + $assertionError("Expected response to have header '#arguments.name#' but it was not found."); + } + if (Len(arguments.value) && hdrs[arguments.name] != arguments.value) { + $assertionError("Expected header '#arguments.name#' to be '#arguments.value#' but got '#hdrs[arguments.name]#'."); + } + return this; + } + + /** + * Assert a cookie exists in the response and optionally matches a value. + * + * @name Cookie name to check + * @value Optional expected cookie value + */ + public TestClient function assertCookie(required string name, string value = "") { + var responseCookies = {}; + if (StructKeyExists(variables.lastResponse, "cookies")) { + responseCookies = variables.lastResponse.cookies; + } + if (!StructKeyExists(responseCookies, arguments.name)) { + $assertionError("Expected response to have cookie '#arguments.name#' but it was not found."); + } + if (Len(arguments.value) && responseCookies[arguments.name] != arguments.value) { + $assertionError("Expected cookie '#arguments.name#' to be '#arguments.value#' but got '#responseCookies[arguments.name]#'."); + } + return this; + } + + // ─── Response Accessors ────────────────────────────────────────── + + /** + * Get the full response struct from the last request. + */ + public struct function response() { + return variables.lastResponse; + } + + /** + * Get the response body as a string. + */ + public string function content() { + if (StructKeyExists(variables.lastResponse, "fileContent")) { + return ToString(variables.lastResponse.fileContent); + } + return ""; + } + + /** + * Get the HTTP status code of the last response. + */ + public numeric function statusCode() { + if (StructKeyExists(variables.lastResponse, "statusCode")) { + // cfhttp returns statusCode as "200 OK" — extract the numeric part + var raw = ToString(variables.lastResponse.statusCode); + return Val(raw); + } + return 0; + } + + /** + * Parse and return the JSON response body as a struct/array. + */ + public any function json() { + var body = content(); + if (!Len(body)) { + return {}; + } + try { + return DeserializeJSON(body); + } catch (any e) { + $assertionError("Cannot parse response body as JSON. Body: #Left(body, 200)#"); + } + } + + /** + * Get the response headers as a struct. + */ + public struct function headers() { + if (StructKeyExists(variables.lastResponse, "responseHeader")) { + return variables.lastResponse.responseHeader; + } + return {}; + } + + // ─── Private Helpers ───────────────────────────────────────────── + + /** + * Execute an HTTP request using cfhttp. + * + * @method HTTP method (GET, POST, PUT, PATCH, DELETE) + * @path URL path + * @params Query string parameters + * @body Request body struct + * @headers Per-request headers + */ + public void function $makeRequest( + required string method, + required string path, + struct params = {}, + struct body = {}, + struct headers = {} + ) { + var fullUrl = variables.baseUrl & arguments.path; + + // Append query string params to the URL + if (!StructIsEmpty(arguments.params)) { + var qs = []; + for (var key in arguments.params) { + ArrayAppend(qs, EncodeForURL(key) & "=" & EncodeForURL(arguments.params[key])); + } + var separator = Find("?", fullUrl) ? "&" : "?"; + fullUrl = fullUrl & separator & ArrayToList(qs, "&"); + } + + // Merge default headers with per-request headers + var mergedHeaders = StructCopy(variables.defaultHeaders); + StructAppend(mergedHeaders, arguments.headers, true); + + var result = {}; + + cfhttp(url = fullUrl, method = arguments.method, timeout = "30", result = "result", redirect = false) { + // Add merged headers + for (var hName in mergedHeaders) { + cfhttpparam(type = "header", name = hName, value = mergedHeaders[hName]); + } + + // Add cookies + for (var cName in variables.cookies) { + cfhttpparam(type = "cookie", name = cName, value = variables.cookies[cName]); + } + + // Add body for POST/PUT/PATCH + if (ListFindNoCase("POST,PUT,PATCH", arguments.method) && !StructIsEmpty(arguments.body)) { + if (variables.sendAsJson) { + cfhttpparam(type = "body", value = SerializeJSON(arguments.body)); + } else { + for (var fName in arguments.body) { + cfhttpparam(type = "formfield", name = fName, value = arguments.body[fName]); + } + } + } + } + + variables.lastResponse = result; + + // Track cookies from response for subsequent requests (session support) + if (StructKeyExists(result, "responseHeader") && StructKeyExists(result.responseHeader, "Set-Cookie")) { + var setCookieHeader = result.responseHeader["Set-Cookie"]; + if (IsSimpleValue(setCookieHeader)) { + setCookieHeader = [setCookieHeader]; + } + for (var cookieStr in setCookieHeader) { + var cookieParts = ListToArray(cookieStr, ";"); + if (ArrayLen(cookieParts)) { + var nameVal = ListToArray(Trim(cookieParts[1]), "="); + if (ArrayLen(nameVal) >= 2) { + variables.cookies[nameVal[1]] = nameVal[2]; + } + } + } + } + } + + /** + * Throw a typed exception for assertion failures. + * TestBox catches these as test failures. + * + * @message Descriptive error message + */ + public void function $assertionError(required string message) { + Throw(type = "TestBox.AssertionFailed", message = arguments.message); + } + +} From ea501549dec6f94073c8eac253b6971473d10d35 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Apr 2026 21:51:42 -0700 Subject: [PATCH 2/5] fix(test): address review issues in HTTP test client - Fix cookie parsing bug: values containing '=' were truncated (ListToArray split on every '='; now uses Find/Mid for first '=' only) - Make $makeRequest and $assertionError private (standalone CFC, not mixin) - Remove redundant exact-match check in assertRedirect() - Change assertJsonPath to 1-based array indexing (CFML convention) - Rename 'client' variable to 'tc' in spec (CFML reserves 'client' scope) - Remove silent-skip patterns: tests now assert preconditions or fail - Add tests for put(), patch(), delete() methods - Add tests for POST with body (form fields + JSON) - Remove framework reload side effect from assertOk() test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/specs/internal/testClientSpec.cfc | 218 +++++++++--------- vendor/wheels/wheelstest/TestClient.cfc | 22 +- 2 files changed, 120 insertions(+), 120 deletions(-) diff --git a/vendor/wheels/tests/specs/internal/testClientSpec.cfc b/vendor/wheels/tests/specs/internal/testClientSpec.cfc index b6284e19f..dc0bbd9be 100644 --- a/vendor/wheels/tests/specs/internal/testClientSpec.cfc +++ b/vendor/wheels/tests/specs/internal/testClientSpec.cfc @@ -5,7 +5,7 @@ component extends="wheels.WheelsTest" { describe("TestClient", () => { beforeEach(() => { - client = $testClient(); + tc = $testClient(); }); describe("initialization", () => { @@ -25,18 +25,33 @@ component extends="wheels.WheelsTest" { describe("request methods", () => { it("get() makes HTTP GET request", () => { - client.get("/"); - expect(client.statusCode()).toBeGT(0); + tc.get("/"); + expect(tc.statusCode()).toBeGT(0); }); it("post() makes HTTP POST request", () => { - client.post("/"); - expect(client.statusCode()).toBeGT(0); + tc.post("/"); + expect(tc.statusCode()).toBeGT(0); + }); + + it("put() makes HTTP PUT request", () => { + tc.put("/"); + expect(tc.statusCode()).toBeGT(0); + }); + + it("patch() makes HTTP PATCH request", () => { + tc.patch("/"); + expect(tc.statusCode()).toBeGT(0); + }); + + it("delete() makes HTTP DELETE request", () => { + tc.delete("/"); + expect(tc.statusCode()).toBeGT(0); }); it("visit() is alias for get()", () => { - client.visit("/"); - expect(client.statusCode()).toBeGT(0); + tc.visit("/"); + expect(tc.statusCode()).toBeGT(0); }); }); @@ -44,88 +59,71 @@ component extends="wheels.WheelsTest" { describe("assertions", () => { it("assertStatus() passes on correct status code", () => { - client.get("/"); - client.assertStatus(client.statusCode()); + tc.get("/"); + tc.assertStatus(tc.statusCode()); }); it("assertStatus() fails on wrong status code", () => { - client.get("/"); + tc.get("/"); expect(function() { - client.assertStatus(999); + tc.assertStatus(999); }).toThrow("TestBox.AssertionFailed"); }); it("assertOk() passes on 200 response", () => { - client.get("/?reload=true&password=wheels"); - client.assertOk(); + tc.get("/"); + tc.assertOk(); }); it("assertNotFound() passes on 404 response", () => { - client.get("/wheels-nonexistent-route-that-should-404"); - client.assertNotFound(); + tc.get("/wheels-nonexistent-route-that-should-404"); + tc.assertNotFound(); }); it("assertSee() finds text in response body", () => { - client.get("/"); - var body = client.content(); - if (Len(body)) { - var snippet = Left(body, 10); - if (Len(snippet)) { - client.assertSee(snippet); - } - } + tc.get("/"); + expect(Len(tc.content())).toBeGT(0, "Response body should not be empty"); + tc.assertSee(Left(tc.content(), 10)); }); it("assertSee() fails when text is not found", () => { - client.get("/"); + tc.get("/"); expect(function() { - client.assertSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ"); + tc.assertSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ"); }).toThrow("TestBox.AssertionFailed"); }); it("assertDontSee() confirms text is absent", () => { - client.get("/"); - client.assertDontSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ"); + tc.get("/"); + tc.assertDontSee("ZZZZZ_THIS_TEXT_SHOULD_NEVER_EXIST_ZZZZZ"); }); it("assertDontSee() fails when text is present", () => { - client.get("/"); - var body = client.content(); - if (Len(body)) { - var snippet = Left(body, 10); - if (Len(snippet)) { - expect(function() { - client.assertDontSee(snippet); - }).toThrow("TestBox.AssertionFailed"); - } - } + tc.get("/"); + expect(Len(tc.content())).toBeGT(0, "Response body should not be empty"); + var snippet = Left(tc.content(), 10); + expect(function() { + tc.assertDontSee(snippet); + }).toThrow("TestBox.AssertionFailed"); }); it("assertJson() validates JSON response", () => { - client.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); - client.assertJson(); + tc.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); + tc.assertJson(); }); it("assertJson() fails on non-JSON response", () => { - client.get("/"); - var body = client.content(); - if (Len(body) && Left(Trim(body), 1) != "{" && Left(Trim(body), 1) != "[") { - expect(function() { - client.assertJson(); - }).toThrow("TestBox.AssertionFailed"); - } - }); - - it("assertRedirect() checks 3xx status", () => { - client.get("/"); - var code = client.statusCode(); - if (code >= 300 && code < 400) { - client.assertRedirect(); - } else { - expect(function() { - client.assertRedirect(); - }).toThrow("TestBox.AssertionFailed"); - } + tc.get("/"); + expect(function() { + tc.assertJson(); + }).toThrow("TestBox.AssertionFailed"); + }); + + it("assertRedirect() fails on non-redirect status", () => { + tc.get("/"); + expect(function() { + tc.assertRedirect(); + }).toThrow("TestBox.AssertionFailed"); }); }); @@ -133,19 +131,19 @@ component extends="wheels.WheelsTest" { describe("request configuration", () => { it("withHeaders() adds custom headers", () => { - client.withHeaders({"X-Custom-Test": "hello"}); - client.get("/"); - expect(client.statusCode()).toBeGT(0); + tc.withHeaders({"X-Custom-Test": "hello"}); + tc.get("/"); + expect(tc.statusCode()).toBeGT(0); }); it("withHeader() adds a single header", () => { - client.withHeader("X-Custom-Test", "hello"); - client.get("/"); - expect(client.statusCode()).toBeGT(0); + tc.withHeader("X-Custom-Test", "hello"); + tc.get("/"); + expect(tc.statusCode()).toBeGT(0); }); it("asJson() sets content type and returns client for chaining", () => { - var result = client.asJson(); + var result = tc.asJson(); expect(result).toBeInstanceOf("wheels.wheelstest.TestClient"); }); @@ -154,46 +152,38 @@ component extends="wheels.WheelsTest" { describe("response accessors", () => { beforeEach(() => { - client.get("/"); + tc.get("/"); }); it("content() returns response body as string", () => { - expect(client.content()).toBeString(); + expect(tc.content()).toBeString(); }); it("statusCode() returns numeric status", () => { - expect(client.statusCode()).toBeNumeric(); + expect(tc.statusCode()).toBeNumeric(); }); it("headers() returns response headers struct", () => { - expect(client.headers()).toBeStruct(); + expect(tc.headers()).toBeStruct(); }); it("response() returns full response struct", () => { - expect(client.response()).toBeStruct(); + expect(tc.response()).toBeStruct(); }); }); describe("chaining", () => { - it("supports fluent chaining: visit().assertOk().assertSee()", () => { - client.visit("/?reload=true&password=wheels"); - var code = client.statusCode(); - if (code == 200) { - var body = client.content(); - if (Len(body)) { - var snippet = Left(body, 5); - if (Len(snippet)) { - client.assertOk().assertSee(snippet); - } - } - } + it("supports fluent chaining: get().assertOk().assertSee()", () => { + tc.get("/"); + expect(Len(tc.content())).toBeGT(0, "Response body should not be empty"); + tc.assertOk().assertSee(Left(tc.content(), 5)); }); it("supports withHeaders().get().assertStatus() chain", () => { - client.withHeader("Accept", "text/html").get("/"); - client.assertStatus(client.statusCode()); + tc.withHeader("Accept", "text/html").get("/"); + tc.assertStatus(tc.statusCode()); }); }); @@ -201,25 +191,21 @@ component extends="wheels.WheelsTest" { describe("assertSeeInOrder", () => { it("passes when texts appear in order", () => { - client.get("/"); - var body = client.content(); - if (Len(body) > 20) { - var first = Mid(body, 1, 5); - var second = Mid(body, 10, 5); - client.assertSeeInOrder([first, second]); - } + tc.get("/"); + var body = tc.content(); + expect(Len(body)).toBeGT(20, "Response body must be >20 chars for ordering test"); + tc.assertSeeInOrder([Mid(body, 1, 5), Mid(body, 10, 5)]); }); it("fails when texts appear out of order", () => { - client.get("/"); - var body = client.content(); - if (Len(body) > 20) { - var first = Mid(body, 10, 5); - var second = Mid(body, 1, 5); - expect(function() { - client.assertSeeInOrder([first, second]); - }).toThrow("TestBox.AssertionFailed"); - } + tc.get("/"); + var body = tc.content(); + expect(Len(body)).toBeGT(20, "Response body must be >20 chars for ordering test"); + var first = Mid(body, 10, 5); + var second = Mid(body, 1, 5); + expect(function() { + tc.assertSeeInOrder([first, second]); + }).toThrow("TestBox.AssertionFailed"); }); }); @@ -227,11 +213,11 @@ component extends="wheels.WheelsTest" { describe("assertJsonPath", () => { it("resolves dot-notation paths in JSON", () => { - client.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); - var jsonData = client.json(); - if (StructKeyExists(jsonData, "totalPass")) { - client.assertJsonPath("totalPass", jsonData.totalPass); - } + tc.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); + tc.assertJson(); + var jsonData = tc.json(); + expect(StructKeyExists(jsonData, "totalPass")).toBeTrue("Expected JSON to contain totalPass key"); + tc.assertJsonPath("totalPass", jsonData.totalPass); }); }); @@ -239,19 +225,33 @@ component extends="wheels.WheelsTest" { describe("assertHeader", () => { it("passes when header exists", () => { - client.get("/"); - client.assertHeader("Content-Type"); + tc.get("/"); + tc.assertHeader("Content-Type"); }); it("fails when header is missing", () => { - client.get("/"); + tc.get("/"); expect(function() { - client.assertHeader("X-Nonexistent-Header-For-Test"); + tc.assertHeader("X-Nonexistent-Header-For-Test"); }).toThrow("TestBox.AssertionFailed"); }); }); + describe("post with body", () => { + + it("sends form fields by default", () => { + tc.post("/", {testField: "testValue"}); + expect(tc.statusCode()).toBeGT(0); + }); + + it("sends JSON body when asJson()", () => { + tc.asJson().post("/", {testField: "testValue"}); + expect(tc.statusCode()).toBeGT(0); + }); + + }); + }); } diff --git a/vendor/wheels/wheelstest/TestClient.cfc b/vendor/wheels/wheelstest/TestClient.cfc index 7e69bd100..f361bbf2b 100644 --- a/vendor/wheels/wheelstest/TestClient.cfc +++ b/vendor/wheels/wheelstest/TestClient.cfc @@ -226,7 +226,7 @@ component { if (StructKeyExists(hdrs, "Location")) { loc = hdrs.Location; } - if (loc != arguments.to && !FindNoCase(arguments.to, loc)) { + if (!FindNoCase(arguments.to, loc)) { $assertionError("Expected redirect to '#arguments.to#' but Location header is '#loc#'."); } } @@ -307,9 +307,9 @@ component { /** * Assert a value at a dot-notation path in the JSON response. - * Array indices are 0-based in the path but 1-based internally (CFML arrays). + * Array indices are 1-based (matching CFML convention). * - * Example: assertJsonPath("users.0.name", "John") + * Example: assertJsonPath("users.1.name", "John") * * @path Dot-notation path into the JSON structure * @expectedValue Expected value at that path @@ -327,10 +327,9 @@ component { for (var i = 1; i <= ArrayLen(segments); i++) { var segment = segments[i]; if (IsNumeric(segment) && IsArray(current)) { - // 0-based input -> 1-based CFML - var idx = Int(segment) + 1; + var idx = Int(segment); if (idx < 1 || idx > ArrayLen(current)) { - $assertionError("JSON path '#arguments.path#' failed: array index #segment# (0-based) is out of bounds (array length: #ArrayLen(current)#)."); + $assertionError("JSON path '#arguments.path#' failed: array index #segment# is out of bounds (array length: #ArrayLen(current)#)."); } current = current[idx]; } else if (IsStruct(current) && StructKeyExists(current, segment)) { @@ -449,7 +448,7 @@ component { * @body Request body struct * @headers Per-request headers */ - public void function $makeRequest( + private void function $makeRequest( required string method, required string path, struct params = {}, @@ -508,9 +507,10 @@ component { for (var cookieStr in setCookieHeader) { var cookieParts = ListToArray(cookieStr, ";"); if (ArrayLen(cookieParts)) { - var nameVal = ListToArray(Trim(cookieParts[1]), "="); - if (ArrayLen(nameVal) >= 2) { - variables.cookies[nameVal[1]] = nameVal[2]; + var pair = Trim(cookieParts[1]); + var eqPos = Find("=", pair); + if (eqPos > 0) { + variables.cookies[Left(pair, eqPos - 1)] = Mid(pair, eqPos + 1, Len(pair) - eqPos); } } } @@ -523,7 +523,7 @@ component { * * @message Descriptive error message */ - public void function $assertionError(required string message) { + private void function $assertionError(required string message) { Throw(type = "TestBox.AssertionFailed", message = arguments.message); } From 6da920f167dbaabb59441543ca2cd9f67b8dc85d Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Apr 2026 22:05:37 -0700 Subject: [PATCH 3/5] fix(test): prevent recursive HTTP deadlock in test client spec Tests that called /wheels/core/tests from within the test runner caused a thread pool deadlock on CI (single-threaded Lucee). Replaced with $fakeClient() helper that sets internal response state directly, allowing assertion logic to be tested without live HTTP calls to the test endpoint. Live HTTP tests still use "/" which is safe. Also adds $setFakeResponse() to TestClient for test isolation, and improves assertSeeInOrder/assertJsonPath/assertRedirect coverage using deterministic fake data instead of parsing live HTML responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/specs/internal/testClientSpec.cfc | 69 ++++++++++++++----- vendor/wheels/wheelstest/TestClient.cfc | 18 +++++ 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/vendor/wheels/tests/specs/internal/testClientSpec.cfc b/vendor/wheels/tests/specs/internal/testClientSpec.cfc index dc0bbd9be..a0ac9f4ae 100644 --- a/vendor/wheels/tests/specs/internal/testClientSpec.cfc +++ b/vendor/wheels/tests/specs/internal/testClientSpec.cfc @@ -1,5 +1,20 @@ component extends="wheels.WheelsTest" { + /** + * Helper: create a TestClient with a pre-set fake response for unit-testing + * assertion logic without making real HTTP calls. + */ + private any function $fakeClient(string body = "", numeric status = 200, struct headers = {}) { + var fc = new wheels.wheelstest.TestClient(); + // Directly set internal state to test assertions in isolation + fc.$setFakeResponse( + statusCode = "#arguments.status# OK", + fileContent = arguments.body, + responseHeader = arguments.headers + ); + return fc; + } + function run() { describe("TestClient", () => { @@ -108,14 +123,15 @@ component extends="wheels.WheelsTest" { }); it("assertJson() validates JSON response", () => { - tc.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); - tc.assertJson(); + var fc = $fakeClient(body = '{"name":"wheels","version":4}'); + fc.assertJson(); + fc.assertJson({name: "wheels"}); }); it("assertJson() fails on non-JSON response", () => { - tc.get("/"); + var fc = $fakeClient(body = "not json"); expect(function() { - tc.assertJson(); + fc.assertJson(); }).toThrow("TestBox.AssertionFailed"); }); @@ -126,6 +142,12 @@ component extends="wheels.WheelsTest" { }).toThrow("TestBox.AssertionFailed"); }); + it("assertRedirect() passes on 3xx status", () => { + var fc = $fakeClient(status = 302, headers = {Location: "/dashboard"}); + fc.assertRedirect(); + fc.assertRedirect(to = "/dashboard"); + }); + }); describe("request configuration", () => { @@ -191,20 +213,14 @@ component extends="wheels.WheelsTest" { describe("assertSeeInOrder", () => { it("passes when texts appear in order", () => { - tc.get("/"); - var body = tc.content(); - expect(Len(body)).toBeGT(20, "Response body must be >20 chars for ordering test"); - tc.assertSeeInOrder([Mid(body, 1, 5), Mid(body, 10, 5)]); + var fc = $fakeClient(body = "alpha beta gamma delta"); + fc.assertSeeInOrder(["alpha", "beta", "gamma"]); }); it("fails when texts appear out of order", () => { - tc.get("/"); - var body = tc.content(); - expect(Len(body)).toBeGT(20, "Response body must be >20 chars for ordering test"); - var first = Mid(body, 10, 5); - var second = Mid(body, 1, 5); + var fc = $fakeClient(body = "alpha beta gamma delta"); expect(function() { - tc.assertSeeInOrder([first, second]); + fc.assertSeeInOrder(["gamma", "alpha"]); }).toThrow("TestBox.AssertionFailed"); }); @@ -213,11 +229,21 @@ component extends="wheels.WheelsTest" { describe("assertJsonPath", () => { it("resolves dot-notation paths in JSON", () => { - tc.get("/wheels/core/tests?db=sqlite&format=json&directory=wheels.tests.specs.internal.model"); - tc.assertJson(); - var jsonData = tc.json(); - expect(StructKeyExists(jsonData, "totalPass")).toBeTrue("Expected JSON to contain totalPass key"); - tc.assertJsonPath("totalPass", jsonData.totalPass); + var fc = $fakeClient(body = '{"user":{"name":"John","roles":["admin","editor"]}}'); + fc.assertJsonPath("user.name", "John"); + }); + + it("resolves 1-based array indices in JSON", () => { + var fc = $fakeClient(body = '{"items":["a","b","c"]}'); + fc.assertJsonPath("items.1", "a"); + fc.assertJsonPath("items.3", "c"); + }); + + it("fails on missing path", () => { + var fc = $fakeClient(body = '{"name":"wheels"}'); + expect(function() { + fc.assertJsonPath("missing.key", "val"); + }).toThrow("TestBox.AssertionFailed"); }); }); @@ -229,6 +255,11 @@ component extends="wheels.WheelsTest" { tc.assertHeader("Content-Type"); }); + it("passes when header matches value", () => { + var fc = $fakeClient(headers = {"X-Custom": "hello"}); + fc.assertHeader("X-Custom", "hello"); + }); + it("fails when header is missing", () => { tc.get("/"); expect(function() { diff --git a/vendor/wheels/wheelstest/TestClient.cfc b/vendor/wheels/wheelstest/TestClient.cfc index f361bbf2b..0563ece8b 100644 --- a/vendor/wheels/wheelstest/TestClient.cfc +++ b/vendor/wheels/wheelstest/TestClient.cfc @@ -437,6 +437,24 @@ component { return {}; } + // ─── Test Helpers ──────────────────────────────────────────── + + /** + * Set a fake response for unit-testing assertions without making HTTP calls. + * Used by test specs to verify assertion logic in isolation. + */ + public void function $setFakeResponse( + string statusCode = "200 OK", + string fileContent = "", + struct responseHeader = {} + ) { + variables.lastResponse = { + statusCode: arguments.statusCode, + fileContent: arguments.fileContent, + responseHeader: arguments.responseHeader + }; + } + // ─── Private Helpers ───────────────────────────────────────────── /** From a077d4e232342757148693e037d3fca8ab0c8b14 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Apr 2026 22:10:02 -0700 Subject: [PATCH 4/5] fix(test): rename assertStatus parameter to avoid Lucee 7 shadowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lucee 7 cannot resolve statusCode() as a method call when the enclosing function has a parameter named statusCode — the parameter shadows the method name. Renamed parameter to expectedStatus. Co-Authored-By: Claude Opus 4.6 (1M context) --- vendor/wheels/wheelstest/TestClient.cfc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vendor/wheels/wheelstest/TestClient.cfc b/vendor/wheels/wheelstest/TestClient.cfc index 0563ece8b..8fc1531c4 100644 --- a/vendor/wheels/wheelstest/TestClient.cfc +++ b/vendor/wheels/wheelstest/TestClient.cfc @@ -171,12 +171,12 @@ component { /** * Assert the response has the given HTTP status code. * - * @statusCode Expected HTTP status code + * @expectedStatus Expected HTTP status code */ - public TestClient function assertStatus(required numeric statusCode) { + public TestClient function assertStatus(required numeric expectedStatus) { var actual = statusCode(); - if (actual != arguments.statusCode) { - $assertionError("Expected status code #arguments.statusCode# but received #actual#."); + if (actual != arguments.expectedStatus) { + $assertionError("Expected status code #arguments.expectedStatus# but received #actual#."); } return this; } From 5036a133ae14126e4b2099ab036023c44f161e8e Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Apr 2026 22:16:50 -0700 Subject: [PATCH 5/5] fix(test): use fake client for status-dependent assertions Root path (/) returns 500 during test runs because the framework is mid-request. Use $fakeClient() for assertOk() and chaining tests that depend on specific status codes. Co-Authored-By: Claude Opus 4.6 (1M context) --- vendor/wheels/tests/specs/internal/testClientSpec.cfc | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/vendor/wheels/tests/specs/internal/testClientSpec.cfc b/vendor/wheels/tests/specs/internal/testClientSpec.cfc index a0ac9f4ae..9f431abb0 100644 --- a/vendor/wheels/tests/specs/internal/testClientSpec.cfc +++ b/vendor/wheels/tests/specs/internal/testClientSpec.cfc @@ -86,8 +86,8 @@ component extends="wheels.WheelsTest" { }); it("assertOk() passes on 200 response", () => { - tc.get("/"); - tc.assertOk(); + var fc = $fakeClient(status = 200); + fc.assertOk(); }); it("assertNotFound() passes on 404 response", () => { @@ -197,10 +197,9 @@ component extends="wheels.WheelsTest" { describe("chaining", () => { - it("supports fluent chaining: get().assertOk().assertSee()", () => { - tc.get("/"); - expect(Len(tc.content())).toBeGT(0, "Response body should not be empty"); - tc.assertOk().assertSee(Left(tc.content(), 5)); + it("supports fluent chaining: assertOk().assertSee()", () => { + var fc = $fakeClient(body = "Welcome to Wheels"); + fc.assertOk().assertSee("Welcome"); }); it("supports withHeaders().get().assertStatus() chain", () => {