import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:plezy/utils/abortable_http_request.dart'; import 'package:plezy/utils/managed_http_client.dart'; void main() { group('ManagedHttpClient', () { test('preserves headers added by request finalization', () async { final inner = _CapturingClient(); final client = ManagedHttpClient(inner, debugLabel: 'test'); addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero)); final request = http.MultipartRequest('POST', Uri.parse('https://example.test/upload')) ..fields['title'] = 'Movie'; final response = await client.send(request); await response.stream.drain(); expect(inner.headers?['content-type'], startsWith('multipart/form-data; boundary=')); expect(inner.body, isNotEmpty); }); test('closeGracefully aborts and cancels an active streamed response before closing inner client', () async { final inner = _StreamingClient(); final client = ManagedHttpClient(inner, debugLabel: 'test'); addTearDown(() async { await client.closeGracefully(drainTimeout: Duration.zero); await inner.dispose(); }); final response = await client.send(http.Request('GET', Uri.parse('https://example.test/slow'))); await client.closeGracefully(drainTimeout: const Duration(milliseconds: 100)); expect(inner.closeCount, 1); expect(inner.responseCancelled, isTrue); await expectLater(inner.abortTrigger, completes); await expectLater(response.stream.toList(), completion(isEmpty)); }); test('closeGracefully can retry after a drain timeout', () async { final inner = _DeferredSendClient(); final client = ManagedHttpClient(inner, debugLabel: 'test'); addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero)); final responseFuture = client.send(http.Request('GET', Uri.parse('https://example.test/slow'))); await Future.delayed(Duration.zero); await client.closeGracefully(drainTimeout: const Duration(milliseconds: 1)); expect(inner.closeCount, 0); var retryCompleted = false; final retryClose = client.closeGracefully(drainTimeout: const Duration(seconds: 1)); unawaited(retryClose.whenComplete(() => retryCompleted = true)); await Future.delayed(const Duration(milliseconds: 10)); expect(retryCompleted, isFalse); inner.completeWithEmptyResponse(); final response = await responseFuture; await response.stream.drain(); await retryClose; expect(inner.closeCount, 1); }); test('preserves final response URL metadata', () async { final finalUrl = Uri.parse('https://example.test/final'); final inner = _UrlResponseClient(finalUrl); final client = ManagedHttpClient(inner, debugLabel: 'test'); addTearDown(() => client.closeGracefully(drainTimeout: Duration.zero)); final response = await client.send(http.Request('GET', Uri.parse('https://example.test/start'))); expect(response, isA().having((r) => r.url, 'url', finalUrl)); await response.stream.drain(); }); }); group('sendAbortableHttpRequest', () { test('aborts the underlying request when its timeout fires', () async { final inner = _HangingClient(); addTearDown(inner.close); await expectLater( sendAbortableHttpRequest( inner, 'GET', Uri.parse('https://example.test/slow'), timeout: const Duration(milliseconds: 1), operation: 'slow request', ), throwsA(isA().having((e) => e.message, 'message', 'slow request timed out')), ); await expectLater(inner.abortTrigger, completes); }); }); } class _CapturingClient extends http.BaseClient { Map? headers; List? body; @override Future send(http.BaseRequest request) async { headers = Map.of(request.headers); body = await request.finalize().toBytes(); return http.StreamedResponse(const Stream>.empty(), 200, request: request); } } class _StreamingClient extends http.BaseClient { _StreamingClient() { responseController = StreamController>( onCancel: () { responseCancelled = true; }, ); } late final StreamController> responseController; Future? abortTrigger; var closeCount = 0; var responseCancelled = false; @override Future send(http.BaseRequest request) async { abortTrigger = (request as http.Abortable).abortTrigger; return http.StreamedResponse(responseController.stream, 200, request: request); } @override void close() { closeCount += 1; } Future dispose() async { await responseController.close(); } } class _DeferredSendClient extends http.BaseClient { final _response = Completer(); http.BaseRequest? request; Future? abortTrigger; var closeCount = 0; @override Future send(http.BaseRequest request) { this.request = request; abortTrigger = (request as http.Abortable).abortTrigger; return _response.future; } void completeWithEmptyResponse() { _response.complete(http.StreamedResponse(const Stream>.empty(), 200, request: request)); } @override void close() { closeCount += 1; } } class _UrlResponseClient extends http.BaseClient { _UrlResponseClient(this.url); final Uri url; @override Future send(http.BaseRequest request) async { return _ResponseWithUrl(const Stream>.empty(), 200, url: url, request: request); } } class _ResponseWithUrl extends http.StreamedResponse implements http.BaseResponseWithUrl { _ResponseWithUrl(super.stream, super.statusCode, {required this.url, super.request}); @override final Uri url; } class _HangingClient extends http.BaseClient { final _response = Completer(); Future? abortTrigger; var closed = false; @override Future send(http.BaseRequest request) { abortTrigger = (request as http.Abortable).abortTrigger; return _response.future; } @override void close() { closed = true; } }