feat(debug): 增加 HTTP 请求响应日志
This commit is contained in:
1 parent
dc4b60270d
commit
7a588898fa
6 files changed
+534
No files matched your search
@@ -0,0 +1,203 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_interceptors.dart';
|
||||
import 'package:fruitshorts/core/data/remote/network_log_interceptor.dart';
|
||||
|
||||
void main() {
|
||||
const baseUrl = 'http://logging.test';
|
||||
const token = 'eyJhbGciOiJIUzM4NCJ9.secret';
|
||||
|
||||
Dio buildDio(
|
||||
List<String> logs,
|
||||
HttpClientAdapter adapter, {
|
||||
bool useEnvelope = true,
|
||||
}) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
responseType: ResponseType.bytes,
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
dio.httpClientAdapter = adapter;
|
||||
dio.interceptors.addAll([
|
||||
AuthHeaderInterceptor(
|
||||
accessTokenReader: () => token,
|
||||
deviceIdReader: () async => 'device-raw-001',
|
||||
),
|
||||
if (useEnvelope) const EnvelopeInterceptor(),
|
||||
NetworkLogInterceptor(logger: logs.add),
|
||||
]);
|
||||
return dio;
|
||||
}
|
||||
|
||||
test(
|
||||
'should_log_request_and_response_lines_with_elapsed_when_requested',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter.fromText(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'title': '中文标题'},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await dio.post<void>(
|
||||
'/app/logging',
|
||||
queryParameters: {'keyword': '中文'},
|
||||
data: {'filter': '中文'},
|
||||
);
|
||||
|
||||
expect(
|
||||
logs.first,
|
||||
contains('[HTTP] → POST $baseUrl/app/logging?keyword='),
|
||||
);
|
||||
expect(logs.join('\n'), contains('[HTTP] → 耗时起点:'));
|
||||
expect(logs.join('\n'), matches(RegExp(r'\[HTTP\] ← 200 \d+ms POST ')));
|
||||
expect(logs.join('\n'), contains('"filter": "中文"'));
|
||||
expect(logs.join('\n'), contains('"中文标题"'));
|
||||
expect(logs.join('\n'), contains('"Authorization":"Bearer e..."'));
|
||||
expect(logs.join('\n'), contains('"X-Device-Id":"device-raw-001"'));
|
||||
expect(logs.join('\n'), isNot(contains(token)));
|
||||
},
|
||||
);
|
||||
|
||||
test('should_truncate_long_body_when_log_exceeds_limit', () async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter.fromText(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'ok',
|
||||
'data': {'text': List.filled(3500, '长').join()},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await dio.get<void>('/app/long');
|
||||
|
||||
final responseLog = logs.singleWhere(
|
||||
(line) => line.startsWith('[HTTP] ← 200'),
|
||||
);
|
||||
const prefix = '[HTTP] ← body:';
|
||||
final body = responseLog.substring(
|
||||
responseLog.indexOf(prefix) + prefix.length,
|
||||
);
|
||||
expect(body.length, NetworkLogInterceptor.maxBodyLength);
|
||||
expect(body, endsWith('…(truncated)'));
|
||||
});
|
||||
|
||||
test(
|
||||
'should_log_plain_text_without_throwing_when_response_is_not_json',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter.fromText('plain text response'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
dio.get<void>('/app/plain'),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(logs.join('\n'), contains('[HTTP] ✗'));
|
||||
expect(logs.join('\n'), contains('plain text response'));
|
||||
},
|
||||
);
|
||||
|
||||
test('should_log_byte_count_only_when_response_is_image', () async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(
|
||||
logs,
|
||||
_RawHttpAdapter(
|
||||
Uint8List.fromList([0xff, 0xd8, 0xff, 0x00]),
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
useEnvelope: false,
|
||||
);
|
||||
|
||||
await dio.get<void>('/files/image.jpg');
|
||||
|
||||
expect(logs.join('\n'), contains('<binary image/jpeg, 4 bytes>'));
|
||||
expect(logs.join('\n'), isNot(contains('�')));
|
||||
});
|
||||
|
||||
test(
|
||||
'should_log_dio_exception_type_and_message_when_request_fails',
|
||||
() async {
|
||||
final logs = <String>[];
|
||||
final dio = buildDio(logs, _ThrowingHttpAdapter());
|
||||
|
||||
await expectLater(
|
||||
dio.get<void>('/app/offline'),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(
|
||||
logs.join('\n'),
|
||||
matches(RegExp(r'\[HTTP\] ✗ connectionError \d+ms .* offline')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _RawHttpAdapter implements HttpClientAdapter {
|
||||
_RawHttpAdapter(this.bytes, {required this.contentType});
|
||||
|
||||
factory _RawHttpAdapter.fromText(String body) {
|
||||
return _RawHttpAdapter(
|
||||
Uint8List.fromList(utf8.encode(body)),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
final Uint8List bytes;
|
||||
final String contentType;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
return ResponseBody.fromBytes(
|
||||
bytes,
|
||||
200,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [contentType],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
class _ThrowingHttpAdapter implements HttpClientAdapter {
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) {
|
||||
throw DioException(
|
||||
requestOptions: options,
|
||||
type: DioExceptionType.connectionError,
|
||||
message: 'offline',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
Reference in new issue
Block a user