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, ); 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, ); expect(restored, detail); }); }); group('ParentLetterException mapping', () { Future errorOf(int status, Object? body) async { final options = RequestOptions(path: 'parent-letters/l1'); final dio = _ThrowingDio( DioException( requestOptions: options, type: DioExceptionType.badResponse, response: Response( 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() .having((e) => e.error, 'error', ParentLetterError.responseFinal) .having((e) => e.allowRetry, 'allowRetry', isFalse), ); }); test('reads the plain-text "Fehler: " 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()); expect( await errorOf(500, {'error': 'letter_not_found'}), isNot(isA()), ); }); }); } /// 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> get( String path, { Object? data, Map? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onReceiveProgress, }) => Future>.error(error); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }