added guardian login with views for their assigned childs
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/access/user_role.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
|
||||
void main() {
|
||||
test('parses the server wire values', () {
|
||||
expect(UserRole.parse('STUDENT'), UserRole.student);
|
||||
expect(UserRole.parse('TEACHER'), UserRole.teacher);
|
||||
expect(UserRole.parse('STAFF'), UserRole.staff);
|
||||
expect(UserRole.parse('PARENT'), UserRole.parent);
|
||||
});
|
||||
|
||||
test('missing or unknown values are unknown, never an error', () {
|
||||
expect(UserRole.parse(null), UserRole.unknown);
|
||||
expect(UserRole.parse('ALUMNUS'), UserRole.unknown);
|
||||
});
|
||||
|
||||
test('hydrated capability states from older versions stay readable', () {
|
||||
final state = CapabilitiesState.fromJson({
|
||||
'viewForeignTimetables': true,
|
||||
'pushNotifications': true,
|
||||
'userType': 'TEACHER',
|
||||
'loaded': true,
|
||||
});
|
||||
expect(state.role, UserRole.teacher);
|
||||
expect(CapabilitiesState.fromJson({}).role, UserRole.unknown);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/auth_link/device_binding.dart';
|
||||
import 'package:marianum_mobile/auth_link/guardian_login_link.dart';
|
||||
import 'package:marianum_mobile/auth_link/pending_guardian_request.dart';
|
||||
|
||||
void main() {
|
||||
final live = Uri.parse('https://connect.marianum-fulda.de');
|
||||
|
||||
group('GuardianLoginLink.parse', () {
|
||||
test('accepts a link of the active server', () {
|
||||
final link = GuardianLoginLink.parse(
|
||||
Uri.parse(
|
||||
'https://connect.marianum-fulda.de/app/guardian-login?rid=r1<=t1',
|
||||
),
|
||||
apiBase: live,
|
||||
);
|
||||
expect(link?.requestId, 'r1');
|
||||
expect(link?.linkToken, 't1');
|
||||
});
|
||||
|
||||
test('rejects links of another server', () {
|
||||
expect(
|
||||
GuardianLoginLink.parse(
|
||||
Uri.parse(
|
||||
'https://connect-beta.marianum-fulda.de/app/guardian-login?rid=r<=t',
|
||||
),
|
||||
apiBase: live,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('respects a path prefix of a custom server', () {
|
||||
final custom = Uri.parse('https://dev.example.org/connect/');
|
||||
expect(
|
||||
GuardianLoginLink.parse(
|
||||
Uri.parse(
|
||||
'https://dev.example.org/connect/app/guardian-login?rid=r<=t',
|
||||
),
|
||||
apiBase: custom,
|
||||
),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects other paths, plain http and missing parameters', () {
|
||||
for (final raw in [
|
||||
'https://connect.marianum-fulda.de/app/other?rid=r<=t',
|
||||
'http://connect.marianum-fulda.de/app/guardian-login?rid=r<=t',
|
||||
'https://connect.marianum-fulda.de/app/guardian-login?rid=r',
|
||||
'https://connect.marianum-fulda.de/app/guardian-login?lt=t',
|
||||
]) {
|
||||
expect(
|
||||
GuardianLoginLink.parse(Uri.parse(raw), apiBase: live),
|
||||
isNull,
|
||||
reason: raw,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('DeviceBinding', () {
|
||||
test('challenge is the base64url SHA-256 of the secret, unpadded', () {
|
||||
// RFC 7636 appendix B test vector.
|
||||
expect(
|
||||
DeviceBinding.challengeFor(
|
||||
'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
|
||||
),
|
||||
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
);
|
||||
});
|
||||
|
||||
test('secrets are random and URL-safe', () {
|
||||
final a = DeviceBinding.generateSecret();
|
||||
final b = DeviceBinding.generateSecret();
|
||||
expect(a, isNot(b));
|
||||
expect(a, matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
||||
});
|
||||
});
|
||||
|
||||
group('PendingGuardianRequest', () {
|
||||
final request = PendingGuardianRequest(
|
||||
requestId: 'r1',
|
||||
email: 'e@x.de',
|
||||
deviceSecret: 's',
|
||||
expiresAt: DateTime.utc(2026, 9, 19, 12, 15),
|
||||
resendAvailableAt: DateTime.utc(2026, 9, 19, 12, 1),
|
||||
);
|
||||
|
||||
test('round-trips through JSON', () {
|
||||
final copy = PendingGuardianRequest.fromJson(request.toJson())!;
|
||||
expect(copy.requestId, 'r1');
|
||||
expect(copy.email, 'e@x.de');
|
||||
expect(copy.expiresAt, request.expiresAt);
|
||||
expect(copy.codeLength, 6);
|
||||
});
|
||||
|
||||
test('corrupt JSON reads as absent', () {
|
||||
expect(PendingGuardianRequest.fromJson({'requestId': 1}), isNull);
|
||||
});
|
||||
|
||||
test('expires at expiresAt', () {
|
||||
expect(request.isExpired(DateTime.utc(2026, 9, 19, 12, 14)), isFalse);
|
||||
expect(request.isExpired(DateTime.utc(2026, 9, 19, 12, 15)), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/push/direct_push_registration.dart';
|
||||
import 'package:marianum_mobile/push/push_secure_storage.dart';
|
||||
|
||||
class _MemoryStorage implements FlutterSecureStorageLike {
|
||||
final Map<String, String> values = {};
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) async => values[key];
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String? value}) async {
|
||||
if (value == null) {
|
||||
values.remove(key);
|
||||
} else {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async => values.remove(key);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('device identifier is generated once and then reused', () async {
|
||||
final storage = _MemoryStorage();
|
||||
final registration = DirectPushRegistration(storage: storage);
|
||||
final first = await registration.deviceIdentifier();
|
||||
expect(first, matches(RegExp(r'^[0-9a-f]{32}$')));
|
||||
expect(await registration.deviceIdentifier(), first);
|
||||
expect(
|
||||
await DirectPushRegistration(storage: storage).deviceIdentifier(),
|
||||
first,
|
||||
);
|
||||
});
|
||||
|
||||
test('separate installs get different identifiers', () async {
|
||||
final a = await DirectPushRegistration(
|
||||
storage: _MemoryStorage(),
|
||||
).deviceIdentifier();
|
||||
final b = await DirectPushRegistration(
|
||||
storage: _MemoryStorage(),
|
||||
).deviceIdentifier();
|
||||
expect(a, isNot(b));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/session/nextcloud_credentials.dart';
|
||||
import 'package:marianum_mobile/session/session.dart';
|
||||
import 'package:marianum_mobile/session/session_codec.dart';
|
||||
|
||||
String _basic(String user, String secret) =>
|
||||
'Basic ${base64Encode(utf8.encode('$user:$secret'))}';
|
||||
|
||||
void main() {
|
||||
group('decodeSession – installs from before guardian accounts', () {
|
||||
test('username + password without a kind is a credential session', () {
|
||||
final session = decodeSession({
|
||||
'username': 'max',
|
||||
'password': 'pw',
|
||||
'nextcloud_app_password': 'app',
|
||||
'nextcloud_app_password_talk': 'talk',
|
||||
});
|
||||
expect(session, isA<CredentialSession>());
|
||||
final credential = session! as CredentialSession;
|
||||
expect(credential.username, 'max');
|
||||
expect(credential.password, 'pw');
|
||||
expect(credential.isDemo, isFalse);
|
||||
expect(credential.nextcloud.appPassword, 'app');
|
||||
expect(credential.nextcloud.appPasswordTalk, 'talk');
|
||||
expect(credential.nextcloud.usesLoginFlow, isFalse);
|
||||
});
|
||||
|
||||
test('login-flow and demo flags are carried over', () {
|
||||
final session =
|
||||
decodeSession({
|
||||
'username': 'demo@x',
|
||||
'password': 'demo',
|
||||
'is_demo': 'true',
|
||||
'nextcloud_login_flow': 'true',
|
||||
})!
|
||||
as CredentialSession;
|
||||
expect(session.isDemo, isTrue);
|
||||
expect(session.nextcloud.usesLoginFlow, isTrue);
|
||||
});
|
||||
|
||||
test('missing password means signed out', () {
|
||||
expect(decodeSession({'username': 'max'}), isNull);
|
||||
expect(decodeSession({}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('decodeSession – guardian', () {
|
||||
test('guardian kind with e-mail', () {
|
||||
final session = decodeSession({
|
||||
'session_kind': 'guardian',
|
||||
'guardian_email': 'eltern@example.org',
|
||||
});
|
||||
expect(session, isA<GuardianSession>());
|
||||
expect(session!.nextcloud, isNull);
|
||||
expect((session as GuardianSession).email, 'eltern@example.org');
|
||||
});
|
||||
|
||||
test('guardian kind without e-mail is invalid', () {
|
||||
expect(decodeSession({'session_kind': 'guardian'}), isNull);
|
||||
});
|
||||
|
||||
test('an unknown kind from a newer app version reads as signed out', () {
|
||||
expect(
|
||||
decodeSession({
|
||||
'session_kind': 'something-new',
|
||||
'username': 'max',
|
||||
'password': 'pw',
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('encodeSessionFields', () {
|
||||
test('round-trips a credential session', () {
|
||||
final original = CredentialSession(
|
||||
username: 'max',
|
||||
password: 'pw',
|
||||
usesLoginFlow: true,
|
||||
);
|
||||
final decoded =
|
||||
decodeSession(encodeSessionFields(original))! as CredentialSession;
|
||||
expect(decoded.username, 'max');
|
||||
expect(decoded.password, 'pw');
|
||||
expect(decoded.nextcloud.usesLoginFlow, isTrue);
|
||||
});
|
||||
|
||||
test('guardian clears credential fields', () {
|
||||
final fields = encodeSessionFields(
|
||||
const GuardianSession(email: 'e@x.de'),
|
||||
);
|
||||
expect(fields['username'], isNull);
|
||||
expect(fields['password'], isNull);
|
||||
expect(fields.containsKey('username'), isTrue);
|
||||
expect(decodeSession(fields), isA<GuardianSession>());
|
||||
});
|
||||
});
|
||||
|
||||
group('NextcloudCredentials', () {
|
||||
const base = NextcloudCredentials(username: 'max', password: 'pw');
|
||||
|
||||
test('prefers the app password once available', () {
|
||||
expect(base.basicAuthHeader, _basic('max', 'pw'));
|
||||
final withApp = base.copyWith(appPassword: () => 'app');
|
||||
expect(withApp.basicAuthHeader, _basic('max', 'app'));
|
||||
expect(withApp.secret, 'app');
|
||||
expect(withApp.realPasswordBasicAuthHeader, _basic('max', 'pw'));
|
||||
});
|
||||
|
||||
test('talk header needs its own app password', () {
|
||||
expect(() => base.talkBasicAuthHeader, throwsStateError);
|
||||
final withTalk = base.copyWith(appPasswordTalk: () => 't');
|
||||
expect(withTalk.talkBasicAuthHeader, _basic('max', 't'));
|
||||
});
|
||||
|
||||
test('login-flow accounts share the flow password for talk', () {
|
||||
final flow = base.copyWith(
|
||||
appPassword: () => 'flow',
|
||||
usesLoginFlow: true,
|
||||
);
|
||||
expect(flow.talkBasicAuthHeader, _basic('max', 'flow'));
|
||||
});
|
||||
|
||||
test('copyWith can clear an app password', () {
|
||||
final cleared = base
|
||||
.copyWith(appPassword: () => 'app')
|
||||
.copyWith(appPassword: () => null);
|
||||
expect(cleared.hasAppPassword, isFalse);
|
||||
expect(cleared.secret, 'pw');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/session/session.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
|
||||
import 'package:marianum_mobile/storage/modules_settings.dart';
|
||||
|
||||
@@ -11,9 +12,7 @@ void main() {
|
||||
'default position', () {
|
||||
// Regression: persisted settings from before the ticker module existed
|
||||
// repeatedly made new modules vanish from bar, "Mehr" and settings list.
|
||||
final stale = Modules.values
|
||||
.where((m) => m != Modules.ticker)
|
||||
.toList();
|
||||
final stale = Modules.values.where((m) => m != Modules.ticker).toList();
|
||||
|
||||
final effective = AppModule.effectiveModuleOrder(settingsWith(stale));
|
||||
|
||||
@@ -72,9 +71,7 @@ void main() {
|
||||
// visible modules must not move or drop it (previously the raw persisted
|
||||
// indices were used, moving the wrong module).
|
||||
final effective = AppModule.effectiveModuleOrder(settingsWith([]));
|
||||
final displayed = effective
|
||||
.where((m) => m != Modules.ticker)
|
||||
.toList();
|
||||
final displayed = effective.where((m) => m != Modules.ticker).toList();
|
||||
final tickerSlot = effective.indexOf(Modules.ticker);
|
||||
|
||||
final result = AppModule.reorderModuleOrder(
|
||||
@@ -92,4 +89,22 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isAvailableFor', () {
|
||||
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('guardians lose exactly the Nextcloud modules', () {
|
||||
final hidden = Modules.values
|
||||
.where((m) => !AppModule.isAvailableFor(m, guardian))
|
||||
.toSet();
|
||||
expect(hidden, {Modules.talk, Modules.files});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||
import 'package:marianum_mobile/background/widget_background_task.dart';
|
||||
import 'package:marianum_mobile/session/session.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/children/child_selection_cubit.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/timetable/policy/timetable_policy.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/timetable/primary/primary_subject_resolver.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/timetable/subject/timetable_subject.dart';
|
||||
import 'package:marianum_mobile/view/pages/absence_report/absence_form_policy.dart';
|
||||
import 'package:marianum_mobile/widget_data/widget_sync.dart';
|
||||
|
||||
const _anna = GuardianChild(id: 'a', firstName: 'Anna', lastName: 'X');
|
||||
const _ben = GuardianChild(id: 'b', firstName: 'Ben', lastName: 'X');
|
||||
const _guardian = GuardianSession(email: 'e@x.de');
|
||||
final _student = CredentialSession(username: 'max', password: 'pw');
|
||||
|
||||
void main() {
|
||||
group('effectiveChild', () {
|
||||
test('keeps a still-linked selection', () {
|
||||
expect(effectiveChild([_anna, _ben], 'b'), _ben);
|
||||
});
|
||||
|
||||
test('falls back to the first child', () {
|
||||
expect(effectiveChild([_anna, _ben], null), _anna);
|
||||
expect(effectiveChild([_anna, _ben], 'gone'), _anna);
|
||||
});
|
||||
|
||||
test('is null without children', () {
|
||||
expect(effectiveChild(const [], 'a'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolvePrimarySubject', () {
|
||||
test('password accounts see their own plan', () {
|
||||
expect(
|
||||
resolvePrimarySubject(
|
||||
session: _student,
|
||||
children: const [],
|
||||
selectedChildId: null,
|
||||
),
|
||||
const OwnTimetable(),
|
||||
);
|
||||
});
|
||||
|
||||
test('guardians see the selected child', () {
|
||||
expect(
|
||||
resolvePrimarySubject(
|
||||
session: _guardian,
|
||||
children: const [_anna, _ben],
|
||||
selectedChildId: 'b',
|
||||
),
|
||||
const ChildTimetable('b'),
|
||||
);
|
||||
});
|
||||
|
||||
test('no plan when signed out or without children', () {
|
||||
expect(
|
||||
resolvePrimarySubject(
|
||||
session: null,
|
||||
children: const [],
|
||||
selectedChildId: null,
|
||||
),
|
||||
const NoTimetable(),
|
||||
);
|
||||
expect(
|
||||
resolvePrimarySubject(
|
||||
session: _guardian,
|
||||
children: const [],
|
||||
selectedChildId: null,
|
||||
),
|
||||
const NoTimetable(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('child subject', () {
|
||||
test('siblings get separate persistent slots', () {
|
||||
expect(
|
||||
const ChildTimetable('a').storageId,
|
||||
isNot(const ChildTimetable('b').storageId),
|
||||
);
|
||||
expect(const ChildTimetable('a').persistent, isTrue);
|
||||
expect(const ChildTimetable('a').supportsCustomEvents, isFalse);
|
||||
});
|
||||
|
||||
test('child plans offer no custom events', () {
|
||||
final p = TimetablePolicy.resolve(
|
||||
subject: const ChildTimetable('a'),
|
||||
capabilities: const CapabilitiesState(userType: 'PARENT'),
|
||||
);
|
||||
expect(p.canManageCustomEvents, isFalse);
|
||||
expect(p.canEditSubjectColors, isTrue);
|
||||
expect(p.canOpenForeign, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AbsenceFormPolicy', () {
|
||||
test('users report for themselves with an editable identity', () {
|
||||
final p = AbsenceFormPolicy.resolve(
|
||||
session: _student,
|
||||
children: const [],
|
||||
selectedChildId: null,
|
||||
)!;
|
||||
expect(p.child, isNull);
|
||||
expect(p.identityEditable, isTrue);
|
||||
});
|
||||
|
||||
test('guardians report for the selected child', () {
|
||||
final p = AbsenceFormPolicy.resolve(
|
||||
session: _guardian,
|
||||
children: const [_anna, _ben],
|
||||
selectedChildId: 'b',
|
||||
)!;
|
||||
expect(p.child, _ben);
|
||||
expect(p.identityEditable, isFalse);
|
||||
});
|
||||
|
||||
test('guardians without children cannot report', () {
|
||||
expect(
|
||||
AbsenceFormPolicy.resolve(
|
||||
session: _guardian,
|
||||
children: const [],
|
||||
selectedChildId: null,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('widget subject', () {
|
||||
test('round-trips primary subjects', () {
|
||||
for (final subject in const [OwnTimetable(), ChildTimetable('a:b')]) {
|
||||
expect(
|
||||
WidgetSync.decodeSubject(WidgetSync.encodeSubject(subject)),
|
||||
subject,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a missing value is the own plan of older installs', () {
|
||||
expect(WidgetSync.decodeSubject(null), const OwnTimetable());
|
||||
});
|
||||
|
||||
test('foreign and empty plans never back the widget', () {
|
||||
expect(WidgetSync.encodeSubject(const NoTimetable()), isNull);
|
||||
expect(WidgetSync.decodeSubject('child:'), isNull);
|
||||
});
|
||||
|
||||
test('background refresh follows the session', () {
|
||||
expect(
|
||||
widgetRefreshSubject(session: _student, stored: null),
|
||||
const OwnTimetable(),
|
||||
);
|
||||
expect(
|
||||
widgetRefreshSubject(
|
||||
session: _guardian,
|
||||
stored: const ChildTimetable('a'),
|
||||
),
|
||||
const ChildTimetable('a'),
|
||||
);
|
||||
// Before the app published a child, a guardian has nothing to fetch.
|
||||
expect(
|
||||
widgetRefreshSubject(session: _guardian, stored: const OwnTimetable()),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
||||
import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/timetable/policy/timetable_policy.dart';
|
||||
import 'package:marianum_mobile/state/app/modules/timetable/subject/timetable_subject.dart';
|
||||
|
||||
ElementTimetable _element(TimetableElementType type, [int id = 7]) =>
|
||||
ElementTimetable((type: type, id: id, label: 'X'));
|
||||
|
||||
void main() {
|
||||
group('TimetableSubject', () {
|
||||
test('own plan keeps the legacy storage slot and persists', () {
|
||||
const own = OwnTimetable();
|
||||
expect(own.storageId, '');
|
||||
expect(own.persistent, isTrue);
|
||||
expect(own.supportsCustomEvents, isTrue);
|
||||
});
|
||||
|
||||
test('element plans get distinct slots and do not persist', () {
|
||||
final room = _element(TimetableElementType.room, 1);
|
||||
final teacher = _element(TimetableElementType.teacher, 1);
|
||||
expect(room.storageId, isNot(teacher.storageId));
|
||||
expect(room.storageId, isNot(const OwnTimetable().storageId));
|
||||
expect(room.persistent, isFalse);
|
||||
expect(room.supportsCustomEvents, isFalse);
|
||||
});
|
||||
|
||||
test('element equality ignores the display label', () {
|
||||
final a = ElementTimetable((
|
||||
type: TimetableElementType.room,
|
||||
id: 3,
|
||||
label: 'A',
|
||||
));
|
||||
final b = ElementTimetable((
|
||||
type: TimetableElementType.room,
|
||||
id: 3,
|
||||
label: 'B',
|
||||
));
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
expect(a, isNot(_element(TimetableElementType.schoolClass, 3)));
|
||||
});
|
||||
});
|
||||
|
||||
group('TimetablePolicy', () {
|
||||
const student = CapabilitiesState(userType: 'STUDENT');
|
||||
const teacher = CapabilitiesState(
|
||||
userType: 'TEACHER',
|
||||
viewForeignTimetables: true,
|
||||
);
|
||||
|
||||
test('own plan offers custom events and subject colours', () {
|
||||
final p = TimetablePolicy.resolve(
|
||||
subject: const OwnTimetable(),
|
||||
capabilities: student,
|
||||
);
|
||||
expect(p.canManageCustomEvents, isTrue);
|
||||
expect(p.canEditSubjectColors, isTrue);
|
||||
expect(p.canOpenForeign, isFalse);
|
||||
expect(p.showClassInsteadOfTeacher, isFalse);
|
||||
});
|
||||
|
||||
test('own teacher plan shows classes and may open foreign plans', () {
|
||||
final p = TimetablePolicy.resolve(
|
||||
subject: const OwnTimetable(),
|
||||
capabilities: teacher,
|
||||
);
|
||||
expect(p.showClassInsteadOfTeacher, isTrue);
|
||||
expect(p.canOpenForeign, isTrue);
|
||||
});
|
||||
|
||||
test('foreign plans are read-only', () {
|
||||
final p = TimetablePolicy.resolve(
|
||||
subject: _element(TimetableElementType.room),
|
||||
capabilities: teacher,
|
||||
);
|
||||
expect(p.canManageCustomEvents, isFalse);
|
||||
expect(p.canEditSubjectColors, isFalse);
|
||||
expect(p.canOpenForeign, isTrue);
|
||||
expect(p.showClassInsteadOfTeacher, isFalse);
|
||||
});
|
||||
|
||||
test('class labels follow the viewed element, not the viewer', () {
|
||||
expect(
|
||||
TimetablePolicy.resolve(
|
||||
subject: _element(TimetableElementType.teacher),
|
||||
capabilities: student,
|
||||
).showClassInsteadOfTeacher,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
TimetablePolicy.resolve(
|
||||
subject: _element(TimetableElementType.student),
|
||||
capabilities: teacher,
|
||||
).showClassInsteadOfTeacher,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('TimetableState.isReady', () {
|
||||
final empty = TimetableState(
|
||||
startDate: DateTime(2026, 9, 14),
|
||||
endDate: DateTime(2026, 9, 18),
|
||||
);
|
||||
|
||||
final withReference = empty.copyWith(
|
||||
rooms: TimetableGetRoomsResponse(result: const []),
|
||||
subjects: TimetableGetSubjectsResponse(result: const []),
|
||||
schoolHolidays: TimetableGetHolidaysResponse(result: const []),
|
||||
);
|
||||
|
||||
test('is false without reference data', () {
|
||||
expect(empty.isReady(needsCustomEvents: false), isFalse);
|
||||
});
|
||||
|
||||
test('only waits for custom events when the subject needs them', () {
|
||||
expect(withReference.isReady(needsCustomEvents: false), isTrue);
|
||||
expect(withReference.isReady(needsCustomEvents: true), isFalse);
|
||||
expect(
|
||||
withReference
|
||||
.copyWith(customEvents: GetCustomTimetableEventResponse(const []))
|
||||
.isReady(needsCustomEvents: true),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/demo/demo_mode.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/auth_login/auth_login_response.dart';
|
||||
import 'package:marianum_mobile/auth_link/device_binding.dart';
|
||||
import 'package:marianum_mobile/auth_link/guardian_login_link.dart';
|
||||
import 'package:marianum_mobile/auth_link/pending_guardian_request.dart';
|
||||
import 'package:marianum_mobile/session/session.dart';
|
||||
import 'package:marianum_mobile/view/login/guardian_login_controller.dart';
|
||||
|
||||
final _now = DateTime.utc(2026, 9, 19, 12);
|
||||
|
||||
class _FakeStore implements PendingGuardianRequestStore {
|
||||
PendingGuardianRequest? stored;
|
||||
|
||||
@override
|
||||
Future<PendingGuardianRequest?> read() async => stored;
|
||||
|
||||
@override
|
||||
Future<void> write(PendingGuardianRequest request) async => stored = request;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => stored = null;
|
||||
}
|
||||
|
||||
class _FakeRequest implements AuthGuardianRequest {
|
||||
String? lastChallenge;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<AuthGuardianRequestResponse> run({
|
||||
required String email,
|
||||
required String deviceChallenge,
|
||||
required String tokenName,
|
||||
}) async {
|
||||
calls++;
|
||||
lastChallenge = deviceChallenge;
|
||||
return AuthGuardianRequestResponse(
|
||||
requestId: 'req-$calls',
|
||||
expiresAt: _now.add(const Duration(minutes: 15)),
|
||||
resendAvailableAt: _now.add(const Duration(seconds: 60)),
|
||||
codeLength: 6,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeVerify implements AuthGuardianVerify {
|
||||
Object? failWith;
|
||||
Map<String, String?>? lastCall;
|
||||
|
||||
@override
|
||||
Future<AuthLoginResponse> run({
|
||||
required String requestId,
|
||||
required String deviceVerifier,
|
||||
required String tokenName,
|
||||
String? code,
|
||||
String? linkToken,
|
||||
}) async {
|
||||
lastCall = {
|
||||
'requestId': requestId,
|
||||
'deviceVerifier': deviceVerifier,
|
||||
'code': code,
|
||||
'linkToken': linkToken,
|
||||
};
|
||||
final failure = failWith;
|
||||
if (failure != null) throw failure;
|
||||
return AuthLoginResponse(
|
||||
token: 't',
|
||||
tokenId: 'id',
|
||||
expiresAt: null,
|
||||
user: AuthLoginUser(
|
||||
id: 'u',
|
||||
username: 'e@x.de',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
userType: 'PARENT',
|
||||
className: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
GuardianLoginException _error(GuardianLoginError error) =>
|
||||
GuardianLoginException(error, userMessage: error.name);
|
||||
|
||||
void main() {
|
||||
late _FakeStore store;
|
||||
late _FakeRequest request;
|
||||
late _FakeVerify verify;
|
||||
late List<Session> signedIn;
|
||||
late DateTime now;
|
||||
|
||||
GuardianLoginController controller() => GuardianLoginController(
|
||||
request: request,
|
||||
verify: verify,
|
||||
store: store,
|
||||
signIn: (s) async => signedIn.add(s),
|
||||
tokenName: () async => 'test',
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
store = _FakeStore();
|
||||
request = _FakeRequest();
|
||||
verify = _FakeVerify();
|
||||
signedIn = [];
|
||||
now = _now;
|
||||
});
|
||||
|
||||
test(
|
||||
'requesting a code persists the request and moves to the code step',
|
||||
() async {
|
||||
final c = controller();
|
||||
expect(await c.requestCode(' E@X.de '), isFalse);
|
||||
expect(c.step, GuardianLoginStep.enterCode);
|
||||
expect(store.stored?.email, 'e@x.de');
|
||||
expect(
|
||||
request.lastChallenge,
|
||||
DeviceBinding.challengeFor(store.stored!.deviceSecret),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a valid code signs in with the device secret and clears the request',
|
||||
() async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
final secret = store.stored!.deviceSecret;
|
||||
expect(await c.submitCode('123 456'), isTrue);
|
||||
expect(verify.lastCall?['code'], '123456');
|
||||
expect(verify.lastCall?['deviceVerifier'], secret);
|
||||
expect(store.stored, isNull);
|
||||
expect(signedIn.single, isA<GuardianSession>());
|
||||
expect((signedIn.single as GuardianSession).isDemo, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('a wrong code keeps the request for another try', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
verify.failWith = _error(GuardianLoginError.invalidCode);
|
||||
expect(await c.submitCode('000000'), isFalse);
|
||||
expect(c.step, GuardianLoginStep.enterCode);
|
||||
expect(c.errorMessage, isNotNull);
|
||||
expect(store.stored, isNotNull);
|
||||
});
|
||||
|
||||
test('an expired request falls back to the e-mail step', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
verify.failWith = _error(GuardianLoginError.requestExpired);
|
||||
await c.submitCode('123456');
|
||||
expect(c.step, GuardianLoginStep.enterEmail);
|
||||
expect(store.stored, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'a link for another request is rejected without a server call',
|
||||
() async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
final ok = await c.submitLink(
|
||||
const GuardianLoginLink(requestId: 'other', linkToken: 'lt'),
|
||||
);
|
||||
expect(ok, isFalse);
|
||||
expect(verify.lastCall, isNull);
|
||||
expect(c.errorMessage, contains('anderen Gerät'));
|
||||
},
|
||||
);
|
||||
|
||||
test('a matching link signs in', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
final ok = await c.submitLink(
|
||||
GuardianLoginLink(requestId: store.stored!.requestId, linkToken: 'lt'),
|
||||
);
|
||||
expect(ok, isTrue);
|
||||
expect(verify.lastCall?['linkToken'], 'lt');
|
||||
});
|
||||
|
||||
test('restore resumes an open request and drops an expired one', () async {
|
||||
await controller().requestCode('e@x.de');
|
||||
final resumed = controller();
|
||||
await resumed.restore();
|
||||
expect(resumed.step, GuardianLoginStep.enterCode);
|
||||
|
||||
now = _now.add(const Duration(hours: 1));
|
||||
final late = controller();
|
||||
await late.restore();
|
||||
expect(late.step, GuardianLoginStep.enterEmail);
|
||||
expect(store.stored, isNull);
|
||||
});
|
||||
|
||||
test('resend is only possible after the cooldown', () async {
|
||||
final c = controller();
|
||||
await c.requestCode('e@x.de');
|
||||
expect(c.canResend(), isFalse);
|
||||
await c.resend();
|
||||
expect(request.calls, 1);
|
||||
now = _now.add(const Duration(seconds: 61));
|
||||
expect(c.canResend(), isTrue);
|
||||
await c.resend();
|
||||
expect(request.calls, 2);
|
||||
});
|
||||
|
||||
test('the demo address signs in locally', () async {
|
||||
final c = controller();
|
||||
expect(await c.requestCode(DemoMode.guardianEmail), isTrue);
|
||||
expect(request.calls, 0);
|
||||
expect(signedIn.single.isDemo, isTrue);
|
||||
});
|
||||
|
||||
group('GuardianLoginException.fromDio', () {
|
||||
DioException failure(int status, Object? body) => DioException(
|
||||
requestOptions: RequestOptions(),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(),
|
||||
statusCode: status,
|
||||
data: body,
|
||||
),
|
||||
);
|
||||
|
||||
test('reads the JSON error body', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(401, {'error': 'invalid_code', 'attemptsLeft': 2}),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.invalidCode);
|
||||
expect(e.attemptsLeft, 2);
|
||||
expect(e.userMessage, contains('Noch 2 Versuche'));
|
||||
});
|
||||
|
||||
test('reads the plain-text error of the generic server handler', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(403, 'Fehler: device_mismatch'),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.deviceMismatch);
|
||||
});
|
||||
|
||||
test('falls back to the status code', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(failure(429, null))
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.rateLimited);
|
||||
});
|
||||
|
||||
test('names an address the school has no guardian access for', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(404, {'error': 'email_not_registered'}),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.emailNotRegistered);
|
||||
expect(e.userMessage, contains('Sekretariat'));
|
||||
});
|
||||
|
||||
test('a bare 404 means the server lacks guardian login, not an '
|
||||
'unknown address', () {
|
||||
for (final body in <Object?>[null, 'Not Found', 'Endpoint not found']) {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(failure(404, body))
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.unsupportedServer, reason: '$body');
|
||||
}
|
||||
final methodMissing =
|
||||
GuardianLoginException.fromDio(failure(405, null))
|
||||
as GuardianLoginException;
|
||||
expect(methodMissing.error, GuardianLoginError.unsupportedServer);
|
||||
});
|
||||
|
||||
test('names a disabled guardian account', () {
|
||||
final e =
|
||||
GuardianLoginException.fromDio(
|
||||
failure(403, {'error': 'account_disabled'}),
|
||||
)
|
||||
as GuardianLoginException;
|
||||
expect(e.error, GuardianLoginError.accountDisabled);
|
||||
});
|
||||
|
||||
test('server errors keep the generic mapping', () {
|
||||
expect(
|
||||
GuardianLoginException.fromDio(failure(500, 'boom')),
|
||||
isNot(isA<GuardianLoginException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user