Files
Client/test/api/marianumconnect/parent_letter_models_test.dart
T

242 lines
8.2 KiB
Dart

import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/errors/auth_exception.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/get_parent_letter.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_exception.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
const _detailJson = '''
{
"id": "l1", "subject": "Wandertag 10b", "preview": "Liebe Eltern",
"sender": {"displayName": "Hr. Müller"},
"sentAt": "2026-09-20T08:15:00", "editedAt": null, "read": false,
"childIds": ["c1"], "attachmentCount": 1, "status": "open",
"deadline": "2026-10-01T23:59:00",
"body": "Liebe Eltern,\\n…",
"attachments": [{"id": "a1", "fileName": "Brief.pdf", "mimeType": "application/pdf", "size": 48211}],
"request": {
"fields": [
{"id": "f1", "type": "single_choice", "label": "Teilnahme?", "required": true,
"options": [{"id": "o1", "label": "Ja"}, {"id": "o2", "label": "Nein"}]},
{"id": "f2", "type": "date_range", "label": "Zeitraum", "required": false, "options": null}
],
"signatureRequired": true, "deadline": "2026-10-01T23:59:00"
},
"children": [
{"childId": "c1", "status": "done", "editable": false,
"response": {"respondedAt": "2026-09-21T19:02:11",
"respondedBy": {"displayName": "A. Hoffmann", "self": true},
"answers": [{"fieldId": "f1", "optionIds": ["o1"], "text": null}], "signed": true}}
],
"thread": {"enabled": true, "messages": [
{"id": "m1", "author": {"displayName": "Hr. Müller", "self": false}, "body": "Danke", "sentAt": "2026-09-21T20:10:00"}
]}
}
''';
void main() {
group('ParentLetterSummary', () {
test('parses the documented inbox entry', () {
final response = ParentLetterListResponse.fromJson({
'items': [
{
'id': 'l1',
'subject': 'Wandertag',
'preview': 'Liebe Eltern',
'sender': {'displayName': 'Hr. Müller'},
'sentAt': '2026-09-20T08:15:00',
'editedAt': null,
'read': false,
'childIds': ['c1', 'c2'],
'attachmentCount': 2,
'status': 'expired',
'deadline': '2026-10-01T23:59:00',
},
],
'hasMore': true,
'unreadCount': 3,
'openCount': 1,
});
final letter = response.items.single;
expect(letter.sender.displayName, 'Hr. Müller');
expect(letter.sentAt, DateTime(2026, 9, 20, 8, 15));
expect(letter.read, isFalse);
expect(letter.childIds, ['c1', 'c2']);
expect(letter.status, ParentLetterStatus.expired);
expect(letter.deadline, DateTime(2026, 10, 1, 23, 59));
expect(response.hasMore, isTrue);
expect(response.unreadCount, 3);
expect(response.openCount, 1);
});
test('explicit nulls and unknown values fall back to safe defaults', () {
final letter = ParentLetterSummary.fromJson({
'id': 'l1',
'subject': null,
'sender': null,
'sentAt': '2026-09-20T08:15:00',
'read': null,
'childIds': null,
'status': 'archived',
'deadline': null,
});
expect(letter.subject, '');
expect(letter.sender.displayName, '');
expect(letter.read, isTrue);
expect(letter.childIds, isEmpty);
expect(letter.status, ParentLetterStatus.info);
expect(letter.deadline, isNull);
});
test('an empty list response is an empty inbox', () {
final response = ParentLetterListResponse.fromJson(const {});
expect(response.items, isEmpty);
expect(response.hasMore, isFalse);
expect(response.unreadCount, 0);
});
});
group('ParentLetterDetail', () {
final detail = ParentLetterDetail.fromJson(
jsonDecode(_detailJson) as Map<String, dynamic>,
);
test('splits the flat object into summary and content', () {
expect(detail.summary.id, 'l1');
expect(detail.summary.status, ParentLetterStatus.open);
expect(detail.content.body, startsWith('Liebe Eltern'));
expect(detail.content.attachments.single.size, 48211);
expect(detail.content.thread.messages.single.author.self, isFalse);
});
test('keeps unknown field types and tolerates null options', () {
final fields = detail.content.request!.fields;
expect(fields[0].type, ParentLetterFieldType.singleChoice);
expect(fields[0].isRequired, isTrue);
expect(fields[0].options.map((o) => o.id), ['o1', 'o2']);
expect(fields[1].type, ParentLetterFieldType.unknown);
expect(fields[1].options, isEmpty);
});
test('parses the per-child response', () {
final child = detail.content.children.single;
expect(child.editable, isFalse);
expect(child.response!.respondedBy.self, isTrue);
expect(child.response!.signed, isTrue);
expect(child.response!.answers.single.optionIds, ['o1']);
});
test('a letter without request, children and thread is pure information', () {
final info = ParentLetterDetail.fromJson({
'id': 'l2',
'sentAt': '2026-09-20T08:15:00',
'request': null,
'thread': null,
});
expect(info.content.request, isNull);
expect(info.content.children, isEmpty);
expect(info.content.thread.enabled, isFalse);
});
test('survives the storage round trip', () {
final restored = ParentLetterDetail.fromJson(
jsonDecode(jsonEncode(detail.toJson())) as Map<String, dynamic>,
);
expect(restored, detail);
});
});
group('ParentLetterException mapping', () {
Future<Object> errorOf(int status, Object? body) async {
final options = RequestOptions(path: 'parent-letters/l1');
final dio = _ThrowingDio(
DioException(
requestOptions: options,
type: DioExceptionType.badResponse,
response: Response<dynamic>(
requestOptions: options,
statusCode: status,
data: body,
),
),
);
try {
await GetParentLetter(dio: dio).run('l1');
} catch (e) {
return e;
}
fail('expected an error');
}
test('reads the JSON error code', () async {
final error = await errorOf(409, {'error': 'response_final'});
expect(
error,
isA<ParentLetterException>()
.having((e) => e.error, 'error', ParentLetterError.responseFinal)
.having((e) => e.allowRetry, 'allowRetry', isFalse),
);
});
test('reads the plain-text "Fehler: <code>" body', () async {
final error = await errorOf(410, 'Fehler: deadline_passed');
expect(
(error as ParentLetterException).error,
ParentLetterError.deadlinePassed,
);
});
test('the code wins over the status', () async {
final error = await errorOf(404, {'error': 'child_not_found'});
expect(
(error as ParentLetterException).error,
ParentLetterError.childNotFound,
);
});
test('falls back to the status without a known code', () async {
expect(
((await errorOf(404, null)) as ParentLetterException).error,
ParentLetterError.letterNotFound,
);
expect(
((await errorOf(409, 'Fehler: whatever')) as ParentLetterException)
.error,
ParentLetterError.responseFinal,
);
});
test('401 and 5xx keep the generic mapping', () async {
expect(await errorOf(401, 'Fehler: x'), isA<AuthException>());
expect(
await errorOf(500, {'error': 'letter_not_found'}),
isNot(isA<ParentLetterException>()),
);
});
});
}
/// Minimal fake Dio whose `get` always fails with the given exception; every
/// other member is unused.
class _ThrowingDio implements Dio {
final DioException error;
_ThrowingDio(this.error);
@override
Future<Response<T>> get<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) => Future<Response<T>>.error(error);
@override
dynamic noSuchMethod(Invocation invocation) =>
super.noSuchMethod(invocation);
}