diff --git a/lib/src/fetch/response.io.dart b/lib/src/fetch/response.io.dart index cd547b0..f86c9a9 100644 --- a/lib/src/fetch/response.io.dart +++ b/lib/src/fetch/response.io.dart @@ -23,19 +23,40 @@ final class NativeResponseHost extends ResponseHost { } class Response implements native.Response { - Response._(this._host); + Response._( + this._host, { + io_headers.Headers? headers, + bool? redirected, + int? status, + String? statusText, + native.ResponseType? type, + String? url, + }) : _headers = headers, + _redirected = redirected, + _status = status, + _statusText = statusText, + _type = type, + _url = url; factory Response([Object? body, native.ResponseInit? init]) { - final host = switch ((body, init)) { - (final Response response, _) => response._host, - (final io.HttpClientResponse response, null) => HttpClientResponseHost( + return switch ((body, init)) { + (final Response response, null) => response.clone(), + (final Response response, _) => _responseFromWrappedResponse( response, + init, ), - (final native.Response response, _) => NativeResponseHost(response), - _ => NativeResponseHost(native.Response(body, init)), + (final io.HttpClientResponse response, null) => Response._( + HttpClientResponseHost(response), + ), + (final native.Response response, null) => Response._( + NativeResponseHost(response.clone()), + ), + (final native.Response response, _) => _responseFromNativeResponse( + response, + init, + ), + _ => Response._(NativeResponseHost(native.Response(body, init))), }; - - return Response._(host); } factory Response.error() => Response(native.Response.error()); @@ -51,6 +72,11 @@ class Response implements native.Response { final ResponseHost _host; io_headers.Headers? _headers; Body? _body; + final bool? _redirected; + final int? _status; + final String? _statusText; + final native.ResponseType? _type; + final String? _url; @override io_headers.Headers get headers { @@ -81,6 +107,9 @@ class Response implements native.Response { @override bool get ok { + final status = _status; + if (status != null) return HttpStatus.isSuccess(status); + return switch (_host) { final HttpClientResponseHost host => HttpStatus.isSuccess( host.value.statusCode, @@ -91,6 +120,9 @@ class Response implements native.Response { @override bool get redirected { + final redirected = _redirected; + if (redirected != null) return redirected; + return switch (_host) { final HttpClientResponseHost host => host.value.redirects.isNotEmpty, final NativeResponseHost host => host.value.redirected, @@ -99,6 +131,9 @@ class Response implements native.Response { @override int get status { + final status = _status; + if (status != null) return status; + return switch (_host) { final HttpClientResponseHost host => host.value.statusCode, final NativeResponseHost host => host.value.status, @@ -107,6 +142,9 @@ class Response implements native.Response { @override String get statusText { + final statusText = _statusText; + if (statusText != null) return statusText; + return switch (_host) { final HttpClientResponseHost host => host.value.reasonPhrase, final NativeResponseHost host => host.value.statusText, @@ -115,6 +153,9 @@ class Response implements native.Response { @override native.ResponseType get type { + final type = _type; + if (type != null) return type; + return switch (_host) { final HttpClientResponseHost _ => native.ResponseType.default_, final NativeResponseHost host => host.value.type, @@ -123,6 +164,9 @@ class Response implements native.Response { @override String get url { + final url = _url; + if (url != null) return url; + return switch (_host) { final HttpClientResponseHost _ => '', final NativeResponseHost host => host.value.url, @@ -189,28 +233,33 @@ class Response implements native.Response { @override Response clone() { return switch (_host) { - final NativeResponseHost host => Response(host.value.clone()), - final HttpClientResponseHost _ => Response( - native.Response( - _bodyForNativeClone(), - native.ResponseInit( - status: status, - statusText: statusText, - headers: io_headers.Headers(headers), + final NativeResponseHost host => Response._( + NativeResponseHost(host.value.clone()), + headers: io_headers.Headers(headers), + redirected: redirected, + status: status, + statusText: statusText, + type: type, + url: url, + ), + final HttpClientResponseHost _ => Response._( + NativeResponseHost( + native.Response( + _bodyForNativeCopy(), + native.ResponseInit( + status: status, + statusText: statusText, + headers: io_headers.Headers(headers), + ), ), ), + redirected: redirected, + type: type, + url: url, ), }; } - Body? _bodyForNativeClone() { - if (!_statusAllowsBody(status)) { - return null; - } - - return body?.clone(); - } - static bool _statusAllowsBody(int status) { return !const { HttpStatus.noContent, @@ -218,4 +267,112 @@ class Response implements native.Response { HttpStatus.notModified, }.contains(status); } + + static Response _responseFromWrappedResponse( + Response response, + native.ResponseInit? init, + ) { + if (init?.status == null && response.status == 0) { + final clone = response.clone(); + return Response._( + clone._host, + headers: io_headers.Headers(init?.headers ?? response.headers), + redirected: response.redirected, + status: response.status, + statusText: init?.statusText ?? response.statusText, + type: response.type, + url: response.url, + ); + } + + return Response._( + NativeResponseHost(_nativeResponseFromWrappedResponse(response, init)), + redirected: response.redirected, + type: response.type, + url: response.url, + ); + } + + static Response _responseFromNativeResponse( + native.Response response, + native.ResponseInit? init, + ) { + if (init?.status == null && response.status == 0) { + return Response._( + NativeResponseHost(response.clone()), + headers: io_headers.Headers(init?.headers ?? response.headers), + redirected: response.redirected, + status: response.status, + statusText: init?.statusText ?? response.statusText, + type: response.type, + url: response.url, + ); + } + + return Response._( + NativeResponseHost(_nativeResponseFromNativeResponse(response, init)), + redirected: response.redirected, + type: response.type, + url: response.url, + ); + } + + static native.Response _nativeResponseFromWrappedResponse( + Response response, + native.ResponseInit? init, + ) { + final sourceHeaders = io_headers.Headers(response.headers); + return _nativeResponseFromCopy( + response._bodyForNativeCopy(), + status: init?.status ?? response.status, + statusText: init?.statusText ?? response.statusText, + headers: init?.headers ?? sourceHeaders, + preserveMissingContentType: + init?.headers == null && !sourceHeaders.has('content-type'), + ); + } + + static native.Response _nativeResponseFromNativeResponse( + native.Response response, + native.ResponseInit? init, + ) { + final sourceHeaders = io_headers.Headers(response.headers); + return _nativeResponseFromCopy( + response.body, + status: init?.status ?? response.status, + statusText: init?.statusText ?? response.statusText, + headers: init?.headers ?? sourceHeaders, + preserveMissingContentType: + init?.headers == null && !sourceHeaders.has('content-type'), + ); + } + + static native.Response _nativeResponseFromCopy( + Body? body, { + required int status, + required String statusText, + required Object? headers, + required bool preserveMissingContentType, + }) { + final response = native.Response( + body, + native.ResponseInit( + status: status, + statusText: statusText, + headers: headers, + ), + ); + if (preserveMissingContentType) { + response.headers.delete('content-type'); + } + return response; + } + + Body? _bodyForNativeCopy() { + if (!_statusAllowsBody(status)) { + return null; + } + + return body; + } } diff --git a/lib/src/fetch/response.js.dart b/lib/src/fetch/response.js.dart index a3b6897..a593957 100644 --- a/lib/src/fetch/response.js.dart +++ b/lib/src/fetch/response.js.dart @@ -29,17 +29,49 @@ final class NativeResponseHost extends ResponseHost { } class Response implements native.Response { - Response._(this._host); + Response._( + this._host, { + js_headers.Headers? headers, + bool? redirected, + int? status, + String? statusText, + native.ResponseType? type, + String? url, + }) : _headers = headers, + _redirected = redirected, + _status = status, + _statusText = statusText, + _type = type, + _url = url; factory Response([Object? body, native.ResponseInit? init]) { - final host = switch ((body, init)) { - (final Response response, _) => response._host, - (final web.Response response, null) => WebResponseHost(response), - (final native.Response response, _) => NativeResponseHost(response), - _ => NativeResponseHost(native.Response(body, init)), + return switch ((body, init)) { + (final Response response, null) => response.clone(), + (final Response response, _) => _responseFromWrappedResponse( + response, + init, + ), + (final web.Response response, null) => Response._( + WebResponseHost(response), + ), + (final web.Response response, _) => _responseFromWebResponse( + response, + init, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: _responseTypeFromValue(response.type), + url: response.url, + ), + (final native.Response response, null) => Response._( + NativeResponseHost(response.clone()), + ), + (final native.Response response, _) => _responseFromNativeResponse( + response, + init, + ), + _ => Response._(NativeResponseHost(native.Response(body, init))), }; - - return Response._(host); } factory Response.error() => Response._(WebResponseHost(web.Response.error())); @@ -57,6 +89,11 @@ class Response implements native.Response { final ResponseHost _host; js_headers.Headers? _headers; Body? _body; + final bool? _redirected; + final int? _status; + final String? _statusText; + final native.ResponseType? _type; + final String? _url; @override js_headers.Headers get headers { @@ -95,6 +132,9 @@ class Response implements native.Response { @override bool get ok { + final status = _status; + if (status != null) return status >= 200 && status <= 299; + return switch (_host) { final WebResponseHost host => host.value.ok, final NativeResponseHost host => host.value.ok, @@ -103,6 +143,9 @@ class Response implements native.Response { @override bool get redirected { + final redirected = _redirected; + if (redirected != null) return redirected; + return switch (_host) { final WebResponseHost host => host.value.redirected, final NativeResponseHost host => host.value.redirected, @@ -111,6 +154,9 @@ class Response implements native.Response { @override int get status { + final status = _status; + if (status != null) return status; + return switch (_host) { final WebResponseHost host => host.value.status, final NativeResponseHost host => host.value.status, @@ -119,6 +165,9 @@ class Response implements native.Response { @override String get statusText { + final statusText = _statusText; + if (statusText != null) return statusText; + return switch (_host) { final WebResponseHost host => host.value.statusText, final NativeResponseHost host => host.value.statusText, @@ -127,6 +176,9 @@ class Response implements native.Response { @override native.ResponseType get type { + final type = _type; + if (type != null) return type; + return switch (_host) { final WebResponseHost host => _responseTypeFromValue(host.value.type), final NativeResponseHost host => host.value.type, @@ -135,6 +187,9 @@ class Response implements native.Response { @override String get url { + final url = _url; + if (url != null) return url; + return switch (_host) { final WebResponseHost host => host.value.url, final NativeResponseHost host => host.value.url, @@ -222,8 +277,25 @@ class Response implements native.Response { @override Response clone() { return switch (_host) { - final WebResponseHost host => Response(host.value.clone()), - final NativeResponseHost host => Response(host.value.clone()), + final WebResponseHost host => _responseFromWebResponse( + host.value, + null, + sourceHeaders: js_headers.Headers(headers), + redirected: redirected, + status: status, + statusText: statusText, + type: type, + url: url, + ), + final NativeResponseHost host => Response._( + NativeResponseHost(host.value.clone()), + headers: js_headers.Headers(headers), + redirected: redirected, + status: status, + statusText: statusText, + type: type, + url: url, + ), }; } @@ -237,4 +309,196 @@ class Response implements native.Response { _ => native.ResponseType.default_, }; } + + static Response _responseFromWrappedResponse( + Response response, + native.ResponseInit? init, + ) { + return switch (response._host) { + final WebResponseHost host => _responseFromWebResponse( + host.value, + init, + sourceHeaders: js_headers.Headers(response.headers), + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + ), + NativeResponseHost() => _responseFromNativeWrappedResponse( + response, + init, + ), + }; + } + + static Response _responseFromWebResponse( + web.Response response, + native.ResponseInit? init, { + Object? sourceHeaders, + bool? redirected, + int? status, + String? statusText, + native.ResponseType? type, + String? url, + }) { + final sourceStatus = status ?? response.status; + final effectiveHeaders = js_headers.Headers( + init?.headers ?? sourceHeaders ?? response.headers, + ); + final effectiveStatusText = + init?.statusText ?? statusText ?? response.statusText; + if (init?.status == null && sourceStatus == 0) { + return Response._( + WebResponseHost(response.clone()), + headers: effectiveHeaders, + redirected: redirected, + status: sourceStatus, + statusText: effectiveStatusText, + type: type, + url: url, + ); + } + + final targetStatus = _validateStatus(init?.status ?? sourceStatus); + if (!_statusAllowsBody(targetStatus) && response.body != null) { + throw ArgumentError.value( + response, + 'body', + 'Response status $targetStatus cannot have a body.', + ); + } + + final source = response.clone(); + return Response._( + WebResponseHost( + web.Response( + source.body, + web.ResponseInit( + status: targetStatus, + statusText: effectiveStatusText, + headers: effectiveHeaders.host, + ), + ), + ), + redirected: redirected, + status: targetStatus, + statusText: effectiveStatusText, + type: type, + url: url, + ); + } + + static Response _responseFromNativeWrappedResponse( + Response response, + native.ResponseInit? init, + ) { + if (init?.status == null && response.status == 0) { + final clone = response.clone(); + return Response._( + clone._host, + headers: js_headers.Headers(init?.headers ?? response.headers), + redirected: response.redirected, + status: response.status, + statusText: init?.statusText ?? response.statusText, + type: response.type, + url: response.url, + ); + } + + return Response._( + NativeResponseHost( + _nativeResponseFromNativeWrappedResponse(response, init), + ), + redirected: response.redirected, + type: response.type, + url: response.url, + ); + } + + static Response _responseFromNativeResponse( + native.Response response, + native.ResponseInit? init, + ) { + if (init?.status == null && response.status == 0) { + return Response._( + NativeResponseHost(response.clone()), + headers: js_headers.Headers(init?.headers ?? response.headers), + redirected: response.redirected, + status: response.status, + statusText: init?.statusText ?? response.statusText, + type: response.type, + url: response.url, + ); + } + + return Response._( + NativeResponseHost(_nativeResponseFromNativeResponse(response, init)), + redirected: response.redirected, + type: response.type, + url: response.url, + ); + } + + static int _validateStatus(int status) { + if (status < 200 || status > 599) { + throw RangeError.range(status, 200, 599, 'status'); + } + return status; + } + + static bool _statusAllowsBody(int status) { + return !const {204, 205, 304}.contains(status); + } + + static native.Response _nativeResponseFromNativeWrappedResponse( + Response response, + native.ResponseInit? init, + ) { + final sourceHeaders = js_headers.Headers(response.headers); + return _nativeResponseFromCopy( + response.body, + status: init?.status ?? response.status, + statusText: init?.statusText ?? response.statusText, + headers: init?.headers ?? sourceHeaders, + preserveMissingContentType: + init?.headers == null && !sourceHeaders.has('content-type'), + ); + } + + static native.Response _nativeResponseFromNativeResponse( + native.Response response, + native.ResponseInit? init, + ) { + final sourceHeaders = js_headers.Headers(response.headers); + return _nativeResponseFromCopy( + response.body, + status: init?.status ?? response.status, + statusText: init?.statusText ?? response.statusText, + headers: init?.headers ?? sourceHeaders, + preserveMissingContentType: + init?.headers == null && !sourceHeaders.has('content-type'), + ); + } + + static native.Response _nativeResponseFromCopy( + Body? body, { + required int status, + required String statusText, + required Object? headers, + required bool preserveMissingContentType, + }) { + final response = native.Response( + body, + native.ResponseInit( + status: status, + statusText: statusText, + headers: headers, + ), + ); + if (preserveMissingContentType) { + response.headers.delete('content-type'); + } + return response; + } } diff --git a/test/response_io_test.dart b/test/response_io_test.dart index e81ee67..c832ebc 100644 --- a/test/response_io_test.dart +++ b/test/response_io_test.dart @@ -35,6 +35,213 @@ void main() { ); }); + test('clones wrapped responses without aliasing body state', () async { + final upstream = Response( + native.Response( + 'cloned response', + native.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: {'x-source': '1'}, + ), + ), + ); + final clone = Response(upstream); + + expect(clone.status, 202); + expect(clone.statusText, 'Accepted'); + expect(clone.headers.get('x-source'), '1'); + expect(upstream.bodyUsed, isFalse); + expect(clone.bodyUsed, isFalse); + + expect(await upstream.text(), 'cloned response'); + expect(upstream.bodyUsed, isTrue); + expect(clone.bodyUsed, isFalse); + expect(await clone.text(), 'cloned response'); + expect(clone.bodyUsed, isTrue); + }); + + test('applies init overrides when copying wrapped responses', () async { + final upstream = Response( + native.Response( + 'source body', + native.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: {'x-source': '1'}, + ), + ), + ); + + final response = Response( + upstream, + native.ResponseInit( + status: 201, + statusText: 'Created', + headers: {'x-init': '1'}, + ), + ); + + expect(response.status, io.HttpStatus.created); + expect(response.statusText, 'Created'); + expect(response.headers.get('x-source'), isNull); + expect(response.headers.get('x-init'), '1'); + expect(upstream.bodyUsed, isFalse); + expect(await response.text(), 'source body'); + expect(upstream.bodyUsed, isFalse); + expect(await upstream.text(), 'source body'); + }); + + test('applies init overrides when copying native responses', () async { + final upstream = native.Response( + 'native body', + native.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: {'x-source': '1'}, + ), + ); + + final response = Response( + upstream, + native.ResponseInit( + status: 201, + statusText: 'Created', + headers: {'x-init': '1'}, + ), + ); + + expect(response.status, io.HttpStatus.created); + expect(response.statusText, 'Created'); + expect(response.headers.get('x-source'), isNull); + expect(response.headers.get('x-init'), '1'); + expect(upstream.bodyUsed, isFalse); + expect(await response.text(), 'native body'); + expect(upstream.bodyUsed, isFalse); + expect(await upstream.text(), 'native body'); + }); + + test( + 'preserves deleted body-derived content-type when copying responses', + () async { + final wrapped = Response('wrapped body'); + expect(wrapped.headers.get('content-type'), 'text/plain;charset=UTF-8'); + + wrapped.headers.delete('content-type'); + final wrappedCopy = Response( + wrapped, + const native.ResponseInit(statusText: 'OK'), + ); + + expect(wrappedCopy.statusText, 'OK'); + expect(wrappedCopy.headers.get('content-type'), isNull); + expect(wrapped.bodyUsed, isFalse); + expect(await wrappedCopy.text(), 'wrapped body'); + expect(wrapped.bodyUsed, isFalse); + expect(await wrapped.text(), 'wrapped body'); + + final upstream = native.Response('native body'); + upstream.headers.delete('content-type'); + final nativeCopy = Response( + upstream, + const native.ResponseInit(statusText: 'OK'), + ); + + expect(nativeCopy.statusText, 'OK'); + expect(nativeCopy.headers.get('content-type'), isNull); + expect(upstream.bodyUsed, isFalse); + expect(await nativeCopy.text(), 'native body'); + expect(upstream.bodyUsed, isFalse); + expect(await upstream.text(), 'native body'); + }, + ); + + test('preserves zero-status error responses when copying with init', () { + final wrapped = Response.error(); + final wrappedCopy = Response( + wrapped, + native.ResponseInit( + statusText: 'Network Error', + headers: {'x-init': '1'}, + ), + ); + + expect(wrappedCopy.status, 0); + expect(wrappedCopy.statusText, 'Network Error'); + expect(wrappedCopy.ok, isFalse); + expect(wrappedCopy.type, native.ResponseType.error); + expect(wrappedCopy.headers.get('x-init'), '1'); + + final upstream = native.Response.error(); + final nativeCopy = Response( + upstream, + native.ResponseInit( + statusText: 'Network Error', + headers: {'x-init': '1'}, + ), + ); + + expect(nativeCopy.status, 0); + expect(nativeCopy.statusText, 'Network Error'); + expect(nativeCopy.ok, isFalse); + expect(nativeCopy.type, native.ResponseType.error); + expect(nativeCopy.headers.get('x-init'), '1'); + }); + + test('rejects copying consumed wrapped responses', () async { + final upstream = Response('used body'); + + expect(await upstream.text(), 'used body'); + expect(upstream.bodyUsed, isTrue); + expect(() => Response(upstream), throwsStateError); + expect( + () => Response(upstream, const native.ResponseInit(status: 201)), + throwsStateError, + ); + }); + + test( + 'copies null-body HttpClientResponse wrappers with init overrides', + () async { + final server = await io.HttpServer.bind( + io.InternetAddress.loopbackIPv4, + 0, + ); + addTearDown(server.close); + + server.listen((request) { + request.response + ..statusCode = io.HttpStatus.noContent + ..close(); + }); + + final client = io.HttpClient(); + addTearDown(client.close); + + final httpRequest = await client.getUrl( + Uri.parse('http://${server.address.host}:${server.port}/empty'), + ); + final httpResponse = await httpRequest.close(); + + final upstream = Response(httpResponse); + + final response = Response( + upstream, + native.ResponseInit( + statusText: 'No Content', + headers: {'x-init': '1'}, + ), + ); + + expect(response.status, io.HttpStatus.noContent); + expect(response.statusText, 'No Content'); + expect(response.headers.get('x-source'), isNull); + expect(response.headers.get('x-init'), '1'); + expect(response.body, isNull); + expect(await response.text(), ''); + }, + ); + test('copies raw HttpHeaders before appending body content-type', () async { final server = await io.HttpServer.bind( io.InternetAddress.loopbackIPv4, @@ -151,8 +358,16 @@ void main() { final httpResponse = await httpRequest.close(); final response = Response(httpResponse); + final copy = Response( + response, + const native.ResponseInit(statusText: 'OK'), + ); expect(response.redirected, isTrue); + expect(copy.redirected, isTrue); + expect(copy.statusText, 'OK'); + expect(await copy.text(), 'ok'); + expect(response.bodyUsed, isFalse); expect(await response.text(), 'ok'); }); diff --git a/test/response_js_test.dart b/test/response_js_test.dart index 6b4d062..fc5db42 100644 --- a/test/response_js_test.dart +++ b/test/response_js_test.dart @@ -42,6 +42,295 @@ void main() { expect(response.headers.get('content-type'), 'text/plain;charset=UTF-8'); }); + test('clones wrapped responses without aliasing body state', () async { + final upstream = Response( + web.Response( + 'cloned response'.toJS, + web.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: {'x-source': '1'}.jsify()! as web.HeadersInit, + ), + ), + ); + final clone = Response(upstream); + + expect(clone.status, 202); + expect(clone.statusText, 'Accepted'); + expect(clone.headers.get('x-source'), '1'); + expect(upstream.bodyUsed, isFalse); + expect(clone.bodyUsed, isFalse); + + expect(await upstream.text(), 'cloned response'); + expect(upstream.bodyUsed, isTrue); + expect(clone.bodyUsed, isFalse); + expect(await clone.text(), 'cloned response'); + expect(clone.bodyUsed, isTrue); + }); + + test('applies init overrides when copying wrapped responses', () async { + final upstream = Response( + web.Response( + 'source body'.toJS, + web.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: {'x-source': '1'}.jsify()! as web.HeadersInit, + ), + ), + ); + + final response = Response( + upstream, + native.ResponseInit( + status: 201, + statusText: 'Created', + headers: {'x-init': '1'}, + ), + ); + + expect(response.status, 201); + expect(response.statusText, 'Created'); + expect(response.headers.get('x-source'), isNull); + expect(response.headers.get('x-init'), '1'); + expect(upstream.bodyUsed, isFalse); + expect(await response.text(), 'source body'); + expect(upstream.bodyUsed, isFalse); + expect(await upstream.text(), 'source body'); + }); + + test('applies init overrides when copying native responses', () async { + final upstream = native.Response( + 'native body', + native.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: {'x-source': '1'}, + ), + ); + + final response = Response( + upstream, + native.ResponseInit( + status: 201, + statusText: 'Created', + headers: {'x-init': '1'}, + ), + ); + + expect(response.status, 201); + expect(response.statusText, 'Created'); + expect(response.headers.get('x-source'), isNull); + expect(response.headers.get('x-init'), '1'); + expect(upstream.bodyUsed, isFalse); + expect(await response.text(), 'native body'); + expect(upstream.bodyUsed, isFalse); + expect(await upstream.text(), 'native body'); + }); + + test( + 'preserves deleted body-derived content-type when copying responses', + () async { + final wrapped = Response('wrapped body'); + expect(wrapped.headers.get('content-type'), 'text/plain;charset=UTF-8'); + + wrapped.headers.delete('content-type'); + final wrappedCopy = Response( + wrapped, + const native.ResponseInit(statusText: 'OK'), + ); + + expect(wrappedCopy.statusText, 'OK'); + expect(wrappedCopy.headers.get('content-type'), isNull); + expect(wrapped.bodyUsed, isFalse); + expect(await wrappedCopy.text(), 'wrapped body'); + expect(wrapped.bodyUsed, isFalse); + expect(await wrapped.text(), 'wrapped body'); + + final upstream = native.Response('native body'); + upstream.headers.delete('content-type'); + final nativeCopy = Response( + upstream, + const native.ResponseInit(statusText: 'OK'), + ); + + expect(nativeCopy.statusText, 'OK'); + expect(nativeCopy.headers.get('content-type'), isNull); + expect(upstream.bodyUsed, isFalse); + expect(await nativeCopy.text(), 'native body'); + expect(upstream.bodyUsed, isFalse); + expect(await upstream.text(), 'native body'); + }, + ); + + test('preserves web wrapper header mutations when copying', () async { + final upstream = Response( + web.Response( + 'web wrapped body'.toJS, + web.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: + {'content-type': 'text/plain', 'x-source': '1'}.jsify()! + as web.HeadersInit, + ), + ), + ); + upstream.headers + ..delete('content-type') + ..set('x-source', '2'); + + final response = Response( + upstream, + const native.ResponseInit(statusText: 'OK'), + ); + + expect(response.status, 202); + expect(response.statusText, 'OK'); + expect(response.headers.get('content-type'), isNull); + expect(response.headers.get('x-source'), '2'); + expect(upstream.bodyUsed, isFalse); + expect(await response.text(), 'web wrapped body'); + expect(upstream.bodyUsed, isFalse); + expect(await upstream.text(), 'web wrapped body'); + }); + + test('preserves fetched web metadata when copying with init', () async { + final upstream = await web.window + .fetch('data:text/plain,fetch%20metadata'.toJS) + .toDart; + final wrapped = Response(upstream); + + expect(wrapped.url, startsWith('data:text/plain')); + + final response = Response( + wrapped, + const native.ResponseInit(statusText: 'OK'), + ); + + expect(response.status, wrapped.status); + expect(response.statusText, 'OK'); + expect(response.type, wrapped.type); + expect(response.url, wrapped.url); + expect(response.redirected, wrapped.redirected); + expect(wrapped.bodyUsed, isFalse); + expect(await response.text(), 'fetch metadata'); + expect(wrapped.bodyUsed, isFalse); + expect(await wrapped.text(), 'fetch metadata'); + }); + + test('uses effective web copy headers for formData', () async { + final upstream = web.Response( + 'a=1'.toJS, + web.ResponseInit( + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + ), + ); + final response = Response( + upstream, + native.ResponseInit( + headers: {'content-type': 'application/x-www-form-urlencoded'}, + ), + ); + + final parsed = await response.formData(); + expect((parsed.get('a') as TextMultipart).value, '1'); + expect(upstream.bodyUsed, isFalse); + + final wrapped = Response( + web.Response( + 'b=2'.toJS, + web.ResponseInit( + headers: {'content-type': 'text/plain'}.jsify()! as web.HeadersInit, + ), + ), + ); + wrapped.headers.set('content-type', 'application/x-www-form-urlencoded'); + final wrappedCopy = Response( + wrapped, + const native.ResponseInit(statusText: 'OK'), + ); + + final wrappedParsed = await wrappedCopy.formData(); + expect((wrappedParsed.get('b') as TextMultipart).value, '2'); + expect(wrapped.bodyUsed, isFalse); + }); + + test('preserves zero-status web responses when copying with init', () { + final rawCopy = Response( + web.Response.error(), + native.ResponseInit( + statusText: 'Network Error', + headers: {'x-init': '1'}, + ), + ); + + expect(rawCopy.status, 0); + expect(rawCopy.statusText, 'Network Error'); + expect(rawCopy.ok, isFalse); + expect(rawCopy.type, native.ResponseType.error); + expect(rawCopy.headers.get('x-init'), '1'); + + final wrapped = Response.error(); + final wrappedCopy = Response( + wrapped, + native.ResponseInit( + statusText: 'Network Error', + headers: {'x-init': '1'}, + ), + ); + + expect(wrappedCopy.status, 0); + expect(wrappedCopy.statusText, 'Network Error'); + expect(wrappedCopy.ok, isFalse); + expect(wrappedCopy.type, native.ResponseType.error); + expect(wrappedCopy.headers.get('x-init'), '1'); + }); + + test('applies init overrides when copying web responses', () async { + final upstream = web.Response( + 'web body'.toJS, + web.ResponseInit( + status: 202, + statusText: 'Accepted', + headers: {'x-source': '1'}.jsify()! as web.HeadersInit, + ), + ); + + final response = Response( + upstream, + native.ResponseInit( + status: 201, + statusText: 'Created', + headers: {'x-init': '1'}, + ), + ); + + expect(response.status, 201); + expect(response.statusText, 'Created'); + expect(response.headers.get('x-source'), isNull); + expect(response.headers.get('x-init'), '1'); + expect(upstream.bodyUsed, isFalse); + expect(await response.text(), 'web body'); + expect(upstream.bodyUsed, isFalse); + expect( + await upstream.text().toDart.then((text) => text.toDart), + 'web body', + ); + }); + + test('rejects copying consumed wrapped responses', () async { + final upstream = Response('used body'); + + expect(await upstream.text(), 'used body'); + expect(upstream.bodyUsed, isTrue); + expect(() => Response(upstream), throwsStateError); + expect( + () => Response(upstream, const native.ResponseInit(status: 201)), + throwsStateError, + ); + }); + test('clone tees a wrapped web.Response body', () async { final upstream = web.Response('cloned body'.toJS);