391 lines
12 KiB
Dart
391 lines
12 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('HTML diagnostics use independent attachment field with an immutable payload', () async {
|
|
final transport = FakeFeedbackHttpTransport();
|
|
final html = Uint8List.fromList(
|
|
utf8.encode('<!doctype html><!-- TPSchedule school diagnostic -->'),
|
|
);
|
|
final submission = FeedbackSubmission.problem(
|
|
id: 'same-id',
|
|
message: '无法导入',
|
|
diagnosticHtml: html,
|
|
);
|
|
html.fillRange(0, html.length, 0);
|
|
await HttpFeedbackSender(
|
|
endpoint: 'https://example.com/v1/feedback',
|
|
transport: transport,
|
|
).send(submission);
|
|
final body = utf8.decode(transport.body!);
|
|
expect(
|
|
body,
|
|
contains(
|
|
'name="diagnostic_html"; filename="TPSchedule-school-page.html"',
|
|
),
|
|
);
|
|
expect(body, contains('TPSchedule school diagnostic'));
|
|
expect(submission.attachments, isEmpty);
|
|
});
|
|
test(
|
|
'old feedback service gets a specific diagnostic-upgrade hint',
|
|
() async {
|
|
final transport = FakeFeedbackHttpTransport()
|
|
..response = FeedbackHttpResponse(
|
|
422,
|
|
utf8.encode(
|
|
'{"error":"validation_error","detail":"Unexpected file field."}',
|
|
),
|
|
);
|
|
await expectLater(
|
|
HttpFeedbackSender(
|
|
endpoint: 'https://example.com/v1/feedback',
|
|
transport: transport,
|
|
).send(
|
|
FeedbackSubmission.problem(
|
|
id: 'same-id',
|
|
message: '问题',
|
|
diagnosticLog: Uint8List.fromList(
|
|
utf8.encode('TPSchedule diagnostic log\n'),
|
|
),
|
|
),
|
|
),
|
|
throwsA(
|
|
isA<FeedbackException>().having(
|
|
(e) => e.message,
|
|
'upgrade hint',
|
|
allOf(contains('日志'), contains('更新')),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
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'),
|
|
);
|
|
},
|
|
);
|
|
}
|