From 1a2cd8b1fd6c81913ba6254223377e6364bfb258 Mon Sep 17 00:00:00 2001 From: Riley Martine Date: Tue, 11 Aug 2026 13:26:17 -0600 Subject: [PATCH] [DX-1114] claude's fix --- .../jervis/remotes/SimpleRestService.groovy | 14 +++- .../SimpleRestServiceNoContentTypeTest.groovy | 79 +++++++++++++++++++ .../jervis/remotes/StaticMocking.groovy | 9 ++- 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/test/groovy/net/gleske/jervis/remotes/SimpleRestServiceNoContentTypeTest.groovy diff --git a/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy b/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy index d2ac9bc6..21f0d5f8 100644 --- a/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy +++ b/src/main/groovy/net/gleske/jervis/remotes/SimpleRestService.groovy @@ -365,8 +365,20 @@ class SimpleRestService { ByteArrayOutputStream errorResponse = new ByteArrayOutputStream() errorResponse << conn.getErrorStream() response_content = errorResponse.toString() - } else { + } else if(response_headers.find { it.key?.equalsIgnoreCase('Content-Type') }) { response_content = conn.getContent().getText() + } else { + // getContent() dispatches on the Content-Type header and throws + // UnknownServiceException('no content-type') when the response + // omits it, so a response with a body but no Content-Type has to + // be read straight off the stream instead. This is a real + // response shape rather than a broken server: Express + // `res.status(200).end()` behind compression middleware sends a + // bodyless 200 chunked, with neither Content-Length nor + // Content-Type, and that must not look like a transport failure. + ByteArrayOutputStream successResponse = new ByteArrayOutputStream() + successResponse << conn.getInputStream() + response_content = successResponse.toString() } response_content } diff --git a/src/test/groovy/net/gleske/jervis/remotes/SimpleRestServiceNoContentTypeTest.groovy b/src/test/groovy/net/gleske/jervis/remotes/SimpleRestServiceNoContentTypeTest.groovy new file mode 100644 index 00000000..849ae9ae --- /dev/null +++ b/src/test/groovy/net/gleske/jervis/remotes/SimpleRestServiceNoContentTypeTest.groovy @@ -0,0 +1,79 @@ +/* + Copyright 2014-2026 Sam Gleske - https://github.com/samrocketman/jervis + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +package net.gleske.jervis.remotes + +import static net.gleske.jervis.remotes.SimpleRestService.apiFetch + +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + Responses which omit the Content-Type header are served by a real + loopback server rather than the static mocks. The behavior under test is + {@link java.net.URLConnection#getContent()} throwing + UnknownServiceException, which belongs to the JDK: a mock declaring + that it throws would only be asserting against itself. + */ +class SimpleRestServiceNoContentTypeTest extends GroovyTestCase { + HttpServer server + String baseUrl + + @Before protected void setUp() { + super.setUp() + // mockStaticUrl replaces URL.metaClass process-wide and no test restores + // it, so whether a real connection is possible here depends on which + // classes ran first. Drop any installed mock; classes which want one + // install it in their own setUp. + GroovySystem.metaClassRegistry.removeMetaClass(URL) + server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + // Zero-length chunked responses, with no Content-Type and no + // Content-Length. sendResponseHeaders(code, 0) means chunked here. + server.createContext('/empty') { exchange -> + exchange.sendResponseHeaders(200, 0) + exchange.getResponseBody().close() + } + server.createContext('/body') { exchange -> + byte[] body = '{"some":"response"}'.bytes + exchange.sendResponseHeaders(200, 0) + exchange.getResponseBody().withCloseable { it << body } + } + server.start() + baseUrl = "http://127.0.0.1:${server.getAddress().getPort()}" + } + @After protected void tearDown() { + server.stop(0) + GroovySystem.metaClassRegistry.removeMetaClass(URL) + super.tearDown() + } + @Test public void test_SimpleRestService_apiFetch_empty_chunked_response_without_content_type() { + // A bodyless chunked 200 is a success, not a transport failure. Before + // this was handled it threw UnknownServiceException('no content-type'). + Map response = apiFetch(new URL("${baseUrl}/empty"), ['Response-Map': true], 'POST', '{}') + assert response.response_code == 200 + assert response.error == false + assert response.content == null + } + @Test public void test_SimpleRestService_apiFetch_response_body_without_content_type() { + // A body still parses when the server never said what it was; the + // Content-Type default only governs the request. + Map response = apiFetch(new URL("${baseUrl}/body"), ['Response-Map': true], 'POST', '{}') + assert response.response_code == 200 + assert response.content == [some: 'response'] + } +} diff --git a/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy b/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy index 41224b08..d46b4f0d 100644 --- a/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy +++ b/src/test/groovy/net/gleske/jervis/remotes/StaticMocking.groovy @@ -140,7 +140,14 @@ JERVIS_MOCKS_PATH=/tmp/mocks ./gradlew clean check }, getHeaderFields: { -> request_meta.data = request_meta.data?.toString() ?: '' - Map header_fields = [(null): Collections.unmodifiableList(['HTTP/1.1 200 OK'])] + // The recorded fixtures are JSON API responses, so the mock + // declares the Content-Type a real one carries. Without it + // every mocked response looks Content-Type-less, which is now + // a distinct (and separately handled) read path in apiFetch. + Map header_fields = [ + (null): Collections.unmodifiableList(['HTTP/1.1 200 OK']), + 'Content-Type': Collections.unmodifiableList(['application/json']) + ] String file = urlToMockFileName(mockedUrl, [request_meta.method, request_meta.data].join(' '), checksumMocks, checksumAlgorithm) if(file in custom_responses.keySet()) { //throw new Exception( custom_responses.get(file) )