Add feedback service, reminder diagnostics and smooth week transitions

This commit is contained in:
Codex
2026-10-14 15:53:21 +08:00
parent c5e736b39b
commit 1f06e3741e
40 changed files with 3982 additions and 25 deletions
@@ -0,0 +1,311 @@
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(
'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'),
);
},
);
}
+320
View File
@@ -0,0 +1,320 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/features/feedback/feedback_page.dart';
import 'package:hepan_everyday/features/feedback/feedback_submission.dart';
class RecordingSender implements FeedbackSender {
final submissions = <FeedbackSubmission>[];
Completer<void>? pending;
bool fail = false;
bool uncertain = false;
@override
Future<void> send(FeedbackSubmission submission) async {
submissions.add(submission);
if (pending != null) await pending!.future;
if (uncertain) throw FeedbackDeliveryUncertain(submission.id);
if (fail) throw const FeedbackException('网络暂时不可用,请重试');
}
}
Future<void> reveal(
WidgetTester tester,
Finder target, {
bool backwards = false,
}) async {
await tester.scrollUntilVisible(
target,
backwards ? -250 : 250,
scrollable: find.byType(Scrollable).first,
);
await tester.pump();
}
void main() {
testWidgets('uncertain delivery retains original payload for status check', (
tester,
) async {
final sender = RecordingSender()..uncertain = true;
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await tester.enterText(find.byKey(const Key('feedback-message')), '原来的反馈');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text('提交成功'), findsNothing);
expect(find.text('提交状态待确认'), findsOneWidget);
expect(find.byKey(const Key('feedback-message')), findsNothing);
expect(find.byKey(const Key('feedback-submit')), findsNothing);
final original = sender.submissions.single;
expect(
tester
.widget<SelectableText>(
find.byKey(const Key('feedback-uncertain-id')),
)
.data,
original.id,
);
sender.uncertain = false;
await reveal(tester, find.byKey(const Key('feedback-check-original')));
await tester.tap(find.byKey(const Key('feedback-check-original')));
await tester.pumpAndSettle();
expect(sender.submissions, hasLength(2));
expect(identical(sender.submissions[0], sender.submissions[1]), isTrue);
expect(find.text('提交成功'), findsOneWidget);
});
test('submission trims relevant fields and rejects whitespace', () {
expect(
() => FeedbackSubmission.problem(id: 'a', message: ' '),
throwsA(isA<FeedbackException>()),
);
final problem = FeedbackSubmission.problem(
id: 'a',
message: ' 页面建议 ',
contact: ' ',
);
expect(problem.message, '页面建议');
expect(problem.contact, isNull);
expect(problem.school, isNull);
final application = FeedbackSubmission.development(
id: 'b',
school: ' 某大学 ',
contact: ' 微信 abc ',
);
expect(application.school, '某大学');
expect(application.contact, '微信 abc');
expect(application.message, isNull);
expect(application.attachments, isEmpty);
expect(
() => FeedbackSubmission.development(id: 'b', school: ' ', contact: 'x'),
throwsA(isA<FeedbackException>()),
);
expect(
() => FeedbackSubmission.development(id: 'b', school: 'x', contact: ' '),
throwsA(isA<FeedbackException>()),
);
});
test('attachment read stops at byte limit and sanitizes names', () async {
var reads = 0;
Stream<List<int>> oversized() async* {
reads++;
yield [1, 2, 3];
reads++;
yield [4, 5, 6];
reads++;
yield [7];
}
await expectLater(
FeedbackAttachment.read(name: 'x.png', stream: oversized(), maxBytes: 4),
throwsA(isA<FeedbackException>()),
);
expect(reads, 2);
final attachment = await FeedbackAttachment.read(
name: '../../unsafe\r\nname.PNG',
stream: Stream.value([1, 2]),
);
expect(attachment.filename, 'unsafename.PNG');
expect(attachment.contentType, 'image/png');
await expectLater(
FeedbackAttachment.read(name: 'x.exe', stream: Stream.value([1])),
throwsA(isA<FeedbackException>()),
);
});
test('submission enforces attachment count and total limits', () async {
final file = await FeedbackAttachment.read(
name: 'x.jpg',
stream: Stream.value([1]),
);
expect(
() => FeedbackSubmission.problem(
id: 'a',
message: 'x',
attachments: [file, file, file, file],
),
throwsA(isA<FeedbackException>()),
);
final large = await FeedbackAttachment.read(
name: 'x.mp4',
stream: Stream.value(Uint8List(8 * 1024 * 1024)),
);
expect(
() => FeedbackSubmission.problem(
id: 'a',
message: 'x',
attachments: [large, large, large],
),
throwsA(isA<FeedbackException>()),
);
});
testWidgets('problem requires message; contact is optional', (tester) async {
final sender = RecordingSender();
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text('请填写问题或建议'), findsOneWidget);
expect(sender.submissions, isEmpty);
await reveal(
tester,
find.byKey(const Key('feedback-message')),
backwards: true,
);
await tester.enterText(find.byKey(const Key('feedback-message')), ' 建议内容 ');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions.single.message, '建议内容');
expect(sender.submissions.single.contact, isNull);
expect(find.text('提交成功'), findsOneWidget);
});
testWidgets('development requires school/contact and excludes hidden data', (
tester,
) async {
final sender = RecordingSender();
await tester.pumpWidget(
MaterialApp(
home: FeedbackPage(
sender: sender,
pickAttachment: () async => FeedbackAttachment.read(
name: 'hidden.png',
stream: Stream.value([1, 2, 3]),
),
),
),
);
await tester.enterText(find.byKey(const Key('feedback-message')), '旧问题');
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
await tester.tap(find.byKey(const Key('feedback-add-media')));
await tester.pumpAndSettle();
await reveal(tester, find.text('我要参与开发'), backwards: true);
await tester.tap(find.text('我要参与开发'));
await tester.pumpAndSettle();
expect(find.byKey(const Key('feedback-message')), findsNothing);
expect(find.byKey(const Key('feedback-add-media')), findsNothing);
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text('请填写学校'), findsOneWidget);
expect(find.text('请填写联系方式'), findsOneWidget);
await tester.enterText(find.byKey(const Key('feedback-school')), ' 某大学 ');
await tester.enterText(
find.byKey(const Key('feedback-contact')),
' QQ 123 ',
);
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
final submission = sender.submissions.single;
expect(submission.kind, FeedbackKind.development);
expect(submission.school, '某大学');
expect(submission.contact, 'QQ 123');
expect(submission.message, isNull);
expect(submission.attachments, isEmpty);
});
testWidgets(
'pending send prevents duplicates; failure preserves data and retry ID',
(tester) async {
final sender = RecordingSender()
..pending = Completer<void>()
..fail = true;
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pump();
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pump();
expect(sender.submissions, hasLength(1));
expect(find.text('提交成功'), findsNothing);
sender.pending!.complete();
await tester.pumpAndSettle();
expect(find.text('网络暂时不可用,请重试'), findsOneWidget);
expect(find.text('问题'), findsOneWidget);
sender
..pending = null
..fail = false;
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions, hasLength(2));
expect(sender.submissions[0].id, sender.submissions[1].id);
expect(find.text('提交成功'), findsOneWidget);
},
);
testWidgets('editing a failed request starts a new submission ID', (
tester,
) async {
final sender = RecordingSender()..fail = true;
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await tester.enterText(find.byKey(const Key('feedback-message')), '原来的问题');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
await reveal(
tester,
find.byKey(const Key('feedback-message')),
backwards: true,
);
await tester.enterText(find.byKey(const Key('feedback-message')), '更新的问题');
sender.fail = false;
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions[1].id, isNot(sender.submissions[0].id));
expect(sender.submissions[1].message, '更新的问题');
});
testWidgets('unconfigured service gives honest error and preserves input', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: FeedbackPage()));
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(find.text(UnconfiguredFeedbackSender.message), findsOneWidget);
expect(find.text('提交成功'), findsNothing);
expect(find.text('问题'), findsOneWidget);
});
testWidgets('selected media can be removed and survives send failure', (
tester,
) async {
final sender = RecordingSender()..fail = true;
await tester.pumpWidget(
MaterialApp(
home: FeedbackPage(
sender: sender,
pickAttachment: () async => FeedbackAttachment.read(
name: 'screen.png',
stream: Stream.value([1, 2, 3]),
),
),
),
);
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
await tester.tap(find.byKey(const Key('feedback-add-media')));
await tester.pumpAndSettle();
expect(find.text('screen.png'), findsOneWidget);
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions.single.attachments, hasLength(1));
expect(find.text('screen.png'), findsOneWidget);
await tester.ensureVisible(find.byTooltip('移除 screen.png'));
await tester.tap(find.byTooltip('移除 screen.png'));
await tester.pumpAndSettle();
expect(find.text('screen.png'), findsNothing);
});
}