Files
TPSchedule/mobile/test/features/feedback_http_sender_test.dart
T

333 lines
10 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/features/feedback/feedback_http_sender.dart';
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
int calls = 0;
Uri? endpoint;
Map<String, String>? headers;
Uint8List? body;
FeedbackHttpResponse response = FeedbackHttpResponse(
200,
utf8.encode('{"success":true,"submission_id":"same-id","status":"sent"}'),
);
Completer<FeedbackHttpResponse>? pending;
@override
Future<FeedbackHttpResponse> post({
required Uri endpoint,
required Map<String, String> headers,
required Uint8List body,
required Duration timeout,
}) async {
calls++;
this.endpoint = endpoint;
this.headers = headers;
this.body = body;
return pending == null ? response : await pending!.future;
}
}
void main() {
test('diagnostics use a separate UTF8 attachment field', () async {
final transport = FakeFeedbackHttpTransport();
await HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
).send(
FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
diagnosticLog: Uint8List.fromList(
utf8.encode('TPSchedule diagnostic log\nlogin_opened'),
),
),
);
final body = utf8.decode(transport.body!);
expect(
body,
contains('name="diagnostics"; filename="TPSchedule-diagnostics.txt"'),
);
expect(body, contains('login_opened'));
});
test(
'matching uncertain delivery is distinct and never exposes server detail',
() async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
final submission = FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
);
for (final code in ['delivery_uncertain', 'storage_unavailable']) {
transport.response = FeedbackHttpResponse(
503,
utf8.encode(
jsonEncode({
'success': false,
'error': code,
'detail': 'private SMTP error',
'submission_id': 'same-id',
'status': 'uncertain',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
isA<FeedbackDeliveryUncertain>()
.having((error) => error.submissionId, 'original ID', 'same-id')
.having(
(error) => error.message,
'safe instructions',
allOf(
contains('请勿修改'),
isNot(contains('private SMTP error')),
),
),
),
);
}
transport.response = FeedbackHttpResponse(
503,
utf8.encode(
jsonEncode({
'success': false,
'error': 'delivery_uncertain',
'submission_id': 'other-id',
'status': 'uncertain',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
allOf(
isA<FeedbackException>(),
isNot(isA<FeedbackDeliveryUncertain>()),
),
),
);
},
);
test(
'permanent media/validation failures give actionable safe messages',
() async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
final submission = FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
);
for (final rejection in [
(status: 415, code: 'unsupported_media', expected: '移除附件'),
(status: 422, code: 'validation_error', expected: '必填内容'),
]) {
transport.response = FeedbackHttpResponse(
rejection.status,
utf8.encode(
jsonEncode({
'success': false,
'error': rejection.code,
'detail': 'untrusted detail',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
isA<FeedbackException>().having(
(error) => error.message,
'actionable message',
allOf(
contains(rejection.expected),
isNot(contains('untrusted detail')),
),
),
),
);
}
transport.response = FeedbackHttpResponse(
503,
utf8.encode(
jsonEncode({
'success': false,
'error': 'smtp_unavailable',
'submission_id': 'same-id',
'status': 'retryable',
}),
),
);
await expectLater(
sender.send(submission),
throwsA(
allOf(
isA<FeedbackException>().having(
(error) => error.message,
'safe retry',
contains('原提交'),
),
isNot(isA<FeedbackDeliveryUncertain>()),
),
),
);
},
);
test('requires HTTPS before contacting transport', () async {
final transport = FakeFeedbackHttpTransport();
final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题');
for (final endpoint in [
'',
'http://example.com/v1/feedback',
'https://user:pass@example.com/v1/feedback',
]) {
await expectLater(
HttpFeedbackSender(
endpoint: endpoint,
transport: transport,
).send(submission),
throwsA(isA<FeedbackException>()),
);
}
expect(transport.calls, 0);
});
test(
'problem multipart contains relevant fields and repeated media parts',
() async {
final transport = FakeFeedbackHttpTransport();
final file = await FeedbackAttachment.read(
name: 'x.png',
stream: Stream.value([1, 2, 3]),
);
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
await sender.send(
FeedbackSubmission.problem(
id: 'same-id',
message: ' 问题 ',
attachments: [file, file],
),
);
final body = utf8.decode(transport.body!, allowMalformed: true);
expect(body, contains('name="submission_id"\r\n\r\nsame-id'));
expect(body, contains('name="type"\r\n\r\nproblem'));
expect(body, contains('name="message"\r\n\r\n问题'));
expect(body, isNot(contains('name="school"')));
expect(body, isNot(contains('name="contact"')));
expect('name="attachments"'.allMatches(body), hasLength(2));
expect(body, contains('Content-Type: image/png'));
expect(
transport.headers!['Content-Type'],
startsWith('multipart/form-data; boundary='),
);
expect(transport.headers!.keys, isNot(contains('Authorization')));
},
);
test('development multipart sends school and contact without problem/media fields', () async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
await sender.send(
FeedbackSubmission.development(
id: 'same-id',
school: '某大学',
contact: 'QQ 123',
),
);
final body = utf8.decode(transport.body!);
expect(body, contains('name="type"\r\n\r\ndevelopment'));
expect(body, contains('name="school"\r\n\r\n某大学'));
expect(body, contains('name="contact"\r\n\r\nQQ 123'));
expect(body, isNot(contains('name="message"')));
expect(body, isNot(contains('name="attachments"')));
});
test('only matching confirmed sent response is successful', () async {
final transport = FakeFeedbackHttpTransport();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
);
final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题');
for (final response in [
FeedbackHttpResponse(200, utf8.encode('{"success":true}')),
FeedbackHttpResponse(
200,
utf8.encode('{"success":true,"submission_id":"other","status":"sent"}'),
),
FeedbackHttpResponse(
200,
utf8.encode(
'{"success":true,"submission_id":"same-id","status":"queued"}',
),
),
FeedbackHttpResponse(
200,
utf8.encode(
'{"success":false,"submission_id":"same-id","status":"sent"}',
),
),
FeedbackHttpResponse(
500,
utf8.encode(
'{"success":true,"submission_id":"same-id","status":"sent"}',
),
),
FeedbackHttpResponse(200, utf8.encode('bad JSON')),
FeedbackHttpResponse(200, Uint8List(65537)),
]) {
transport.response = response;
await expectLater(
sender.send(submission),
throwsA(isA<FeedbackException>()),
);
}
});
test(
'timeout exposes safe error and repeated request keeps submission ID',
() async {
final transport = FakeFeedbackHttpTransport()
..pending = Completer<FeedbackHttpResponse>();
final sender = HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
timeout: const Duration(milliseconds: 1),
);
final submission = FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
);
await expectLater(
sender.send(submission),
throwsA(isA<FeedbackException>()),
);
transport.pending = null;
await sender.send(submission);
expect(transport.calls, 2);
expect(
utf8.decode(transport.body!),
contains('name="submission_id"\r\n\r\nsame-id'),
);
},
);
}