added guardian letters with chat and multiple answer functionalities

This commit is contained in:
2026-09-20 16:12:45 +02:00
parent 67c935c05b
commit 2423c1a75e
68 changed files with 8348 additions and 226 deletions
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/access/access_requirement.dart';
import 'package:marianum_mobile/session/session.dart';
void main() {
final student = CredentialSession(username: 'max', password: 'pw');
const guardian = GuardianSession(email: 'e@x.de');
test('nextcloud is only met by sessions with a Nextcloud identity', () {
expect(AccessRequirement.nextcloud.isMetBy(student), isTrue);
expect(AccessRequirement.nextcloud.isMetBy(guardian), isFalse);
expect(AccessRequirement.nextcloud.isMetBy(null), isFalse);
});
test('guardian is only met by guardian sessions', () {
expect(AccessRequirement.guardian.isMetBy(guardian), isTrue);
expect(AccessRequirement.guardian.isMetBy(student), isFalse);
expect(AccessRequirement.guardian.isMetBy(null), isFalse);
});
test('a set is met only when every requirement is met', () {
expect(<AccessRequirement>{}.areMetBy(null), isTrue);
expect(
{
AccessRequirement.guardian,
AccessRequirement.nextcloud,
}.areMetBy(guardian),
isFalse,
);
});
}
@@ -0,0 +1,241 @@
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);
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/demo/data/demo_parent_letters.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
void main() {
test('every demo inbox letter resolves to a consistent detail', () {
final inbox = DemoParentLetters.list();
expect(inbox.items, isNotEmpty);
expect(
inbox.unreadCount,
inbox.items.where((letter) => !letter.read).length,
);
for (final letter in inbox.items) {
final detail = DemoParentLetters.detail(letter.id);
expect(detail.summary.id, letter.id);
expect(detail.summary.status, letter.status);
expect(
detail.content.children.map((child) => child.childId),
detail.content.request == null ? isEmpty : letter.childIds,
);
// The inbox is hydrated, so the fixtures must survive storage.
expect(
ParentLetterDetail.fromJson(
jsonDecode(jsonEncode(detail.toJson())) as Map<String, dynamic>,
).content,
detail.content,
);
}
});
test('a letter read in the session stays read', () {
final unread = DemoParentLetters.list().items.firstWhere((l) => !l.read);
DemoParentLetters.markRead(unread.id);
final after = DemoParentLetters.list().items.firstWhere(
(l) => l.id == unread.id,
);
expect(after.read, isTrue);
});
}
+66
View File
@@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/push/push_tap_router.dart';
import 'package:marianum_mobile/push/push_target.dart';
void main() {
group('resolvePushTarget', () {
test('a parent letter push routes by its letter id', () {
final target = resolvePushTarget({
'source': 'connect',
'type': 'parent-letter',
'parentLetterId': 'l1',
'event': 'reply',
'title': 'Hr. Müller: Wandertag',
});
expect(
target,
isA<ParentLetterTarget>().having((t) => t.letterId, 'letterId', 'l1'),
);
});
test('newsletter pushes route by newsletterId', () {
expect(
resolvePushTarget({'source': 'connect', 'newsletterId': 'n1'}),
isA<NewsletterTarget>().having((t) => t.newsletterId, 'id', 'n1'),
);
});
test('chat pushes accept every token key in use', () {
for (final key in ['chatToken', 'token', 'roomToken']) {
expect(
resolvePushTarget({key: 'abc', 'nid': 5}),
isA<ChatTarget>().having((t) => t.chatToken, 'chatToken', 'abc'),
reason: key,
);
}
});
test('empty ids and unrelated data name no target', () {
expect(resolvePushTarget({'parentLetterId': ''}), isNull);
expect(resolvePushTarget({'type': 'widget-refresh'}), isNull);
expect(resolvePushTarget({'chatToken': 7}), isNull);
});
});
group('PushTapRouter.handleResponse', () {
setUp(() => PushTapRouter.pendingTarget.value = null);
NotificationResponse tap(String payload) => NotificationResponse(
notificationResponseType: NotificationResponseType.selectedNotification,
payload: payload,
);
test('publishes the target of a tapped notification', () {
PushTapRouter.handleResponse(tap(jsonEncode({'parentLetterId': 'l1'})));
expect(PushTapRouter.pendingTarget.value, isA<ParentLetterTarget>());
});
test('ignores broken payloads', () {
PushTapRouter.handleResponse(tap('not json'));
expect(PushTapRouter.pendingTarget.value, isNull);
});
});
}
+12 -5
View File
@@ -29,9 +29,15 @@ void main() {
final effective = AppModule.effectiveModuleOrder(settingsWith(custom));
// parentLetters and ticker are both missing and follow timetable in the
// declared order, so they line up behind it.
expect(
effective.indexOf(Modules.parentLetters),
effective.indexOf(Modules.timetable) + 1,
);
expect(
effective.indexOf(Modules.ticker),
effective.indexOf(Modules.timetable) + 1,
effective.indexOf(Modules.parentLetters) + 1,
);
expect(effective.toSet(), Modules.values.toSet());
});
@@ -94,10 +100,11 @@ void main() {
final student = CredentialSession(username: 'max', password: 'pw');
const guardian = GuardianSession(email: 'e@x.de');
test('password accounts see every module', () {
for (final m in Modules.values) {
expect(AppModule.isAvailableFor(m, student), isTrue, reason: m.name);
}
test('password accounts see every module but the guardian ones', () {
final hidden = Modules.values
.where((m) => !AppModule.isAvailableFor(m, student))
.toSet();
expect(hidden, {Modules.parentLetters});
});
test('guardians lose exactly the Nextcloud modules', () {
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import 'package:marianum_mobile/state/app/modules/parent_letters/bloc/parent_letters_state.dart';
import 'package:marianum_mobile/state/app/modules/parent_letters/parent_letters_logic.dart';
ParentLetterSummary _letter(
String id, {
bool read = true,
List<String> childIds = const ['c1'],
}) => ParentLetterSummary(
id: id,
sentAt: DateTime(2026, 9, 20),
read: read,
childIds: childIds,
);
void main() {
test('firstPageState takes over list and counters', () {
final state = firstPageState(
ParentLetterListResponse(
items: [_letter('a')],
hasMore: true,
unreadCount: 2,
openCount: 1,
),
);
expect(state.letters.single.id, 'a');
expect(state.hasMore, isTrue);
expect(state.unreadCount, 2);
expect(state.openCount, 1);
});
test('appendOlderPage appends without duplicating known letters', () {
final state = ParentLettersState(
letters: [_letter('a'), _letter('b')],
hasMore: true,
);
final next = appendOlderPage(
state,
ParentLetterListResponse(items: [_letter('b'), _letter('c')]),
);
expect(next.letters.map((l) => l.id), ['a', 'b', 'c']);
expect(next.hasMore, isFalse);
});
group('withLetterRead', () {
final state = ParentLettersState(
letters: [_letter('a', read: false), _letter('b')],
unreadCount: 1,
);
test('marks the letter and lowers the counter', () {
final next = withLetterRead(state, 'a');
expect(next.letters.first.read, isTrue);
expect(next.unreadCount, 0);
});
test('returns the same state for read or unknown letters', () {
expect(identical(withLetterRead(state, 'b'), state), isTrue);
expect(identical(withLetterRead(state, 'x'), state), isTrue);
});
test('never drops the counter below zero', () {
final drifted = state.copyWith(unreadCount: 0);
expect(withLetterRead(drifted, 'a').unreadCount, 0);
});
});
group('withSummary', () {
final open = _letter('b').copyWith(status: ParentLetterStatus.open);
final state = ParentLettersState(
letters: [_letter('a'), open],
openCount: 3,
);
test('replaces the letter in place and lowers the open counter', () {
final next = withSummary(
state,
open.copyWith(status: ParentLetterStatus.done),
);
expect(next.letters.map((l) => l.id), ['a', 'b']);
expect(next.letters.last.status, ParentLetterStatus.done);
expect(next.openCount, 2);
});
test('keeps the counter while the letter stays open', () {
expect(withSummary(state, open.copyWith(read: false)).openCount, 3);
});
test('ignores letters the inbox does not hold', () {
expect(identical(withSummary(state, _letter('x')), state), isTrue);
});
});
test('filterLettersByChild keeps letters that concern the child', () {
final letters = [
_letter('a', childIds: ['c1']),
_letter('b', childIds: ['c2']),
_letter('c', childIds: ['c1', 'c2']),
];
expect(filterLettersByChild(letters, null), letters);
expect(filterLettersByChild(letters, 'c2').map((l) => l.id), ['b', 'c']);
});
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/utils/random_id.dart';
void main() {
test('randomUuidV4 is a well-formed version-4 UUID', () {
final pattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
);
final ids = {for (var i = 0; i < 200; i++) randomUuidV4()};
expect(ids, hasLength(200));
for (final id in ids) {
expect(id, matches(pattern));
}
});
}
@@ -0,0 +1,168 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/parent_letters/parent_letter_models.dart';
import 'package:marianum_mobile/view/pages/parent_letters/parent_letter_form_policy.dart';
import 'package:marianum_mobile/view/pages/parent_letters/widgets/parent_letter_attachments.dart';
const _choice = ParentLetterField(
id: 'f1',
type: ParentLetterFieldType.singleChoice,
label: 'Teilnahme?',
isRequired: true,
options: [
ParentLetterOption(id: 'yes', label: 'Ja'),
ParentLetterOption(id: 'no', label: 'Nein'),
],
);
const _optionalChoice = ParentLetterField(
id: 'f2',
type: ParentLetterFieldType.singleChoice,
);
const _unknownRequired = ParentLetterField(id: 'f3', isRequired: true);
const _unknownOptional = ParentLetterField(id: 'f4');
const _answered = ParentLetterResponse(
answers: [
ParentLetterAnswer(fieldId: 'f1', optionIds: ['no']),
],
);
ParentLetterChildState _child({
bool editable = true,
ParentLetterResponse? response,
}) => ParentLetterChildState(
childId: 'c1',
editable: editable,
response: response,
);
void main() {
group('ParentLetterFormPolicy.resolve', () {
test('letters without a request have no form', () {
expect(
ParentLetterFormPolicy.resolve(request: null, child: _child()),
isNull,
);
});
test('mode follows the server verdict and the existing response', () {
const request = ParentLetterRequest(fields: [_choice]);
ParentLetterFormMode mode(ParentLetterChildState child) =>
ParentLetterFormPolicy.resolve(request: request, child: child)!.mode;
expect(mode(_child()), ParentLetterFormMode.open);
expect(mode(_child(response: _answered)), ParentLetterFormMode.change);
expect(
mode(_child(editable: false, response: _answered)),
ParentLetterFormMode.done,
);
expect(mode(_child(editable: false)), ParentLetterFormMode.closed);
});
test('an unknown required field blocks the form', () {
final policy = ParentLetterFormPolicy.resolve(
request: const ParentLetterRequest(fields: [_choice, _unknownRequired]),
child: _child(),
)!;
expect(policy.mode, ParentLetterFormMode.unsupported);
expect(policy.canSubmit, isFalse);
});
test('an answered letter stays readable despite unknown fields', () {
final policy = ParentLetterFormPolicy.resolve(
request: const ParentLetterRequest(fields: [_choice, _unknownRequired]),
child: _child(editable: false, response: _answered),
)!;
expect(policy.mode, ParentLetterFormMode.done);
});
test('unknown optional fields are skipped', () {
final policy = ParentLetterFormPolicy.resolve(
request: const ParentLetterRequest(fields: [_choice, _unknownOptional]),
child: _child(),
)!;
expect(policy.mode, ParentLetterFormMode.open);
expect(policy.fields, [_choice]);
});
});
group('submit semantics', () {
ParentLetterFormPolicy policy(
ParentLetterRequest request, {
ParentLetterResponse? response,
}) => ParentLetterFormPolicy.resolve(
request: request,
child: _child(response: response),
)!;
test('acknowledgement', () {
final p = policy(const ParentLetterRequest());
expect(p.isAcknowledgement, isTrue);
expect(p.submitIsFinal, isTrue);
expect(p.submitLabel, 'Zur Kenntnis genommen');
expect(p.isComplete(const {}), isTrue);
expect(p.answersFor(const {}), isEmpty);
});
test('plain choice can be changed later', () {
const request = ParentLetterRequest(fields: [_choice]);
expect(policy(request).submitIsFinal, isFalse);
expect(policy(request).submitLabel, 'Rückmeldung absenden');
expect(
policy(request, response: _answered).submitLabel,
'Rückmeldung ändern',
);
});
test('signature makes the response final', () {
final p = policy(
const ParentLetterRequest(fields: [_choice], signatureRequired: true),
);
expect(p.submitIsFinal, isTrue);
expect(p.isAcknowledgement, isFalse);
expect(p.submitLabel, 'Unterschreiben und absenden');
});
test('only required fields must be answered', () {
final p = policy(
const ParentLetterRequest(fields: [_choice, _optionalChoice]),
);
expect(p.isComplete(const {}), isFalse);
expect(p.isComplete(const {'f2': 'x'}), isFalse);
expect(p.isComplete(const {'f1': 'yes'}), isTrue);
});
test('answers contain exactly the selected fields', () {
final p = policy(
const ParentLetterRequest(fields: [_choice, _optionalChoice]),
);
expect(p.answersFor(const {'f1': 'yes', 'stale': 'x'}), const [
ParentLetterAnswer(fieldId: 'f1', optionIds: ['yes']),
]);
});
test('selectionOf restores a previous response', () {
expect(ParentLetterFormPolicy.selectionOf(_answered), {'f1': 'no'});
expect(ParentLetterFormPolicy.selectionOf(null), isEmpty);
});
});
group('safeAttachmentFileName', () {
String name(String fileName) => safeAttachmentFileName(
ParentLetterAttachment(id: 'a', fileName: fileName),
);
test('keeps ordinary names', () {
expect(name('Elternbrief 10b.pdf'), 'Elternbrief 10b.pdf');
});
test('strips path parts and reserved characters', () {
expect(name('../../etc/passwd'), 'passwd');
expect(name(r'C:\Users\x\brief?.pdf'), 'brief_.pdf');
});
test('never returns an empty or relative name', () {
expect(name(''), 'Anhang');
expect(name('foo/..'), 'Anhang');
});
});
}