diff --git a/DEVLOG.md b/DEVLOG.md index 27cf7a4..a9d9149 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2783,3 +2783,19 @@ Keine Commits in dieser Session. - deploy/mabea-eskalation.timer.template | 12 ++++++ --- +## 2026-09-04 18:35 – 18:36 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** flutter_app + +### Commits +- c68d39b Flutter: Offline-Queue für Kontroll-Erfassung (Prompt 17-Härtung) + +### Geänderte Dateien +- DEVLOG.md | 30 ++++ +- flutter_app/DEVLOG.md | 15 ++ +- flutter_app/README.md | 18 ++- +- flutter_app/lib/offline/queue.dart | 205 ++++++++++++++++++++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 225 +++++++++++++++++++-------- +- flutter_app/pubspec.yaml | 3 + + +--- diff --git a/flutter_app/DEVLOG.md b/flutter_app/DEVLOG.md index 100bd4d..477f995 100644 --- a/flutter_app/DEVLOG.md +++ b/flutter_app/DEVLOG.md @@ -31,3 +31,19 @@ Keine Commits in dieser Session. - deploy/mabea-eskalation.timer.template | 12 ++++++ --- +## 2026-09-04 18:36 – 18:38 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 30 ++++ +- flutter_app/DEVLOG.md | 15 ++ +- flutter_app/README.md | 18 ++- +- flutter_app/lib/offline/queue.dart | 205 ++++++++++++++++++++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 225 +++++++++++++++++++-------- +- flutter_app/pubspec.yaml | 3 + + +--- diff --git a/flutter_app/README.md b/flutter_app/README.md index 9374529..1494f0b 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -54,7 +54,15 @@ Reconnect (`connectivity_plus`) und periodisch alle 15s - gleiches Muster wie `KontrollScreen` gleicht per 2s-Reconciliation-Loop den Anzeigezustand mit der Queue ab, Abschluss ist gesperrt, solange Einträge offen sind. -## Bewusst noch nicht umgesetzt (Unterschied zur React-PWA) +## Tests -- Keine Tests (Flutter-Widget-/Integrationstests) - Sprintplan sieht Testphase 4 - primär für die PWA vor (Playwright); Android-Testabdeckung ist noch offen. +```bash +flutter test +``` + +`http.Client` ist per Konstruktor injizierbar (`ApiClient(httpClient: ...)`), Tests +mocken ihn mit `mocktail` statt echten Netzwerkaufrufen - kein Server/Emulator nötig. +`test/offline_queue_test.dart` nutzt `sqflite_common_ffi` (reines Dart, keine +Plattform-Channels), um die sqflite-Queue ohne Gerät/Emulator zu testen. Deckt ab: +`ApiClient` (Login, Token-Header, Fehlerfälle), Offline-Queue (Erfolg/Netzwerkfehler/ +4xx-kein-Retry), Login-Screen und Objektliste-Suche als Widget-Tests. diff --git a/flutter_app/lib/api/api_client.dart b/flutter_app/lib/api/api_client.dart index 07b6316..d8f308f 100644 --- a/flutter_app/lib/api/api_client.dart +++ b/flutter_app/lib/api/api_client.dart @@ -17,9 +17,10 @@ class ApiException implements Exception { } class ApiClient { - ApiClient({required this.baseUrl}); + ApiClient({required this.baseUrl, http.Client? httpClient}) : _http = httpClient ?? http.Client(); final String baseUrl; + final http.Client _http; String? _token; Future ladeToken() async { @@ -40,7 +41,7 @@ class ApiClient { bool get istEingeloggt => _token != null; Future login(String username, String password) async { - final response = await http.post( + final response = await _http.post( Uri.parse('$baseUrl/auth/login'), headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: {'username': username, 'password': password}, @@ -69,19 +70,19 @@ class ApiClient { late http.Response response; switch (method) { case 'POST': - response = await http.post(uri, headers: headers, body: encodedBody); + response = await _http.post(uri, headers: headers, body: encodedBody); break; case 'PUT': - response = await http.put(uri, headers: headers, body: encodedBody); + response = await _http.put(uri, headers: headers, body: encodedBody); break; case 'PATCH': - response = await http.patch(uri, headers: headers, body: encodedBody); + response = await _http.patch(uri, headers: headers, body: encodedBody); break; case 'DELETE': - response = await http.delete(uri, headers: headers); + response = await _http.delete(uri, headers: headers); break; default: - response = await http.get(uri, headers: headers); + response = await _http.get(uri, headers: headers); } if (response.statusCode >= 400) { diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index a5b62bf..4a072c1 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -20,6 +20,8 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 + mocktail: ^1.0.4 + sqflite_common_ffi: ^2.3.4+4 flutter: uses-material-design: true diff --git a/flutter_app/test/api_client_test.dart b/flutter_app/test/api_client_test.dart new file mode 100644 index 0000000..f7a963d --- /dev/null +++ b/flutter_app/test/api_client_test.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mabea_app/api/api_client.dart'; + +class _MockClient extends Mock implements http.Client {} + +void main() { + late _MockClient httpClient; + late ApiClient apiClient; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + httpClient = _MockClient(); + apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + }); + + group('login', () { + test('speichert Token bei Erfolg', () async { + when(() => httpClient.post( + Uri.parse('http://test.local/api/v1/auth/login'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer( + (_) async => http.Response(jsonEncode({'access_token': 'abc-token'}), 200), + ); + + final token = await apiClient.login('mitarbeiter1', 'test-passwort-123'); + + expect(token, 'abc-token'); + expect(apiClient.istEingeloggt, isTrue); + }); + + test('wirft ApiException bei falschem Passwort', () async { + when(() => httpClient.post( + Uri.parse('http://test.local/api/v1/auth/login'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer( + (_) async => http.Response(jsonEncode({'detail': 'falsch'}), 401), + ); + + expect( + () => apiClient.login('mitarbeiter1', 'falsch'), + throwsA(isA().having((e) => e.status, 'status', 401)), + ); + }); + }); + + group('request', () { + test('sendet Bearer-Token, sobald eingeloggt', () async { + when(() => httpClient.post( + Uri.parse('http://test.local/api/v1/auth/login'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response(jsonEncode({'access_token': 'abc-token'}), 200)); + await apiClient.login('mitarbeiter1', 'test-passwort-123'); + + when(() => httpClient.get( + Uri.parse('http://test.local/api/v1/objekte'), + headers: any(named: 'headers'), + )).thenAnswer((_) async => http.Response(jsonEncode([]), 200)); + + await apiClient.request('/objekte'); + + final captured = verify(() => httpClient.get( + Uri.parse('http://test.local/api/v1/objekte'), + headers: captureAny(named: 'headers'), + )).captured; + final headers = captured.single as Map; + expect(headers['Authorization'], 'Bearer abc-token'); + }); + + test('wirft ApiException bei 4xx-Antwort', () async { + when(() => httpClient.get( + Uri.parse('http://test.local/api/v1/objekte/1'), + headers: any(named: 'headers'), + )).thenAnswer((_) async => http.Response(jsonEncode({'detail': 'nicht gefunden'}), 404)); + + expect( + () => apiClient.request('/objekte/1'), + throwsA(isA().having((e) => e.status, 'status', 404)), + ); + }); + }); +} diff --git a/flutter_app/test/offline_queue_test.dart b/flutter_app/test/offline_queue_test.dart new file mode 100644 index 0000000..5a6bd2c --- /dev/null +++ b/flutter_app/test/offline_queue_test.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:mabea_app/api/api_client.dart'; +import 'package:mabea_app/offline/queue.dart'; + +class _MockClient extends Mock implements http.Client {} + +/// OfflineQueue ist ein Singleton mit eigenem Datenbank-Handle (analog zur +/// PWA-Queue, siehe frontend/src/offline/queue.ts) - für isolierte Tests +/// hier über Reflection auf das private Feld nicht nötig: jeder Test nutzt +/// eigene kontrolleId/materialId-Kombinationen, damit sich Einträge nicht +/// überschneiden, plus Aufräumen am Ende. +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + late _MockClient httpClient; + late ApiClient apiClient; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + httpClient = _MockClient(); + apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + }); + + test('enqueue speichert Eintrag, verschwindet nach erfolgreicher Übertragung', () async { + const kontrolleId = 'k-erfolg'; + when(() => httpClient.put( + Uri.parse('http://test.local/api/v1/kontrollen/$kontrolleId/positionen/10'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response(jsonEncode({'id': 'kp-1'}), 200)); + + await OfflineQueue.instance.enqueue(kontrolleId: kontrolleId, materialId: 10, istmenge: '5'); + var eintraege = await OfflineQueue.instance.eintraegeFuerKontrolle(kontrolleId); + expect(eintraege, hasLength(1)); + expect(eintraege.single.status, 'wartet'); + + await OfflineQueue.instance.synchronisiere(apiClient); + + eintraege = await OfflineQueue.instance.eintraegeFuerKontrolle(kontrolleId); + expect(eintraege, isEmpty); + }); + + test('Netzwerkfehler lässt Eintrag auf "wartet" stehen, kein Datenverlust', () async { + const kontrolleId = 'k-netzwerkfehler'; + when(() => httpClient.put( + Uri.parse('http://test.local/api/v1/kontrollen/$kontrolleId/positionen/20'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenThrow(const SocketException('Keine Verbindung')); + + await OfflineQueue.instance.enqueue(kontrolleId: kontrolleId, materialId: 20, istmenge: '3'); + await OfflineQueue.instance.synchronisiere(apiClient); + + final eintraege = await OfflineQueue.instance.eintraegeFuerKontrolle(kontrolleId); + expect(eintraege, hasLength(1)); + expect(eintraege.single.status, 'wartet'); + }); + + test('4xx-Antwort setzt Eintrag auf "fehler", kein Auto-Retry', () async { + const kontrolleId = 'k-4xx'; + when(() => httpClient.put( + Uri.parse('http://test.local/api/v1/kontrollen/$kontrolleId/positionen/30'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response(jsonEncode({'detail': 'Objekt gesperrt'}), 409)); + + await OfflineQueue.instance.enqueue(kontrolleId: kontrolleId, materialId: 30, istmenge: '3'); + await OfflineQueue.instance.synchronisiere(apiClient); + var eintraege = await OfflineQueue.instance.eintraegeFuerKontrolle(kontrolleId); + expect(eintraege.single.status, 'fehler'); + expect(eintraege.single.fehlerText, contains('409')); + + // Erneuter Sync-Lauf darf den Fehler-Eintrag NICHT automatisch erneut senden. + await OfflineQueue.instance.synchronisiere(apiClient); + verify(() => httpClient.put( + Uri.parse('http://test.local/api/v1/kontrollen/$kontrolleId/positionen/30'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + + await OfflineQueue.instance.entferneFehlerEintrag(kontrolleId, 30); + eintraege = await OfflineQueue.instance.eintraegeFuerKontrolle(kontrolleId); + expect(eintraege, isEmpty); + }); +} diff --git a/flutter_app/test/widget/login_screen_test.dart b/flutter_app/test/widget/login_screen_test.dart new file mode 100644 index 0000000..3ed4976 --- /dev/null +++ b/flutter_app/test/widget/login_screen_test.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mabea_app/api/api_client.dart'; +import 'package:mabea_app/screens/login_screen.dart'; + +class _MockClient extends Mock implements http.Client {} + +void main() { + setUpAll(() { + registerFallbackValue(Uri.parse('http://test.local')); + }); + + late _MockClient httpClient; + late ApiClient apiClient; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + httpClient = _MockClient(); + apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + }); + + testWidgets('erfolgreicher Login navigiert zur Objektliste', (tester) async { + when(() => httpClient.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(jsonEncode({'access_token': 'abc-token'}), 200)); + when(() => httpClient.get(any(), headers: any(named: 'headers'))) + .thenAnswer((_) async => http.Response(jsonEncode([]), 200)); + + await tester.pumpWidget(MaterialApp(home: LoginScreen(apiClient: apiClient))); + + await tester.enterText(find.widgetWithText(TextField, 'Login'), 'mitarbeiter1'); + await tester.enterText(find.widgetWithText(TextField, 'Passwort'), 'test-passwort-123'); + await tester.tap(find.widgetWithText(FilledButton, 'Anmelden')); + await tester.pumpAndSettle(); + + expect(find.text('Objekte'), findsOneWidget); + }); + + testWidgets('falsches Passwort zeigt Fehlermeldung, bleibt auf Login', (tester) async { + when(() => httpClient.post(any(), headers: any(named: 'headers'), body: any(named: 'body'))) + .thenAnswer((_) async => http.Response(jsonEncode({'detail': 'falsch'}), 401)); + + await tester.pumpWidget(MaterialApp(home: LoginScreen(apiClient: apiClient))); + + await tester.enterText(find.widgetWithText(TextField, 'Login'), 'mitarbeiter1'); + await tester.enterText(find.widgetWithText(TextField, 'Passwort'), 'falsch'); + await tester.tap(find.widgetWithText(FilledButton, 'Anmelden')); + await tester.pumpAndSettle(); + + expect(find.text('Login oder Passwort falsch'), findsOneWidget); + }); +} diff --git a/flutter_app/test/widget/objekt_list_screen_test.dart b/flutter_app/test/widget/objekt_list_screen_test.dart new file mode 100644 index 0000000..c80c5bf --- /dev/null +++ b/flutter_app/test/widget/objekt_list_screen_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:mabea_app/api/api_client.dart'; +import 'package:mabea_app/screens/objekt_list_screen.dart'; + +class _MockClient extends Mock implements http.Client {} + +void main() { + setUpAll(() { + registerFallbackValue(Uri.parse('http://test.local')); + }); + + late _MockClient httpClient; + late ApiClient apiClient; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + httpClient = _MockClient(); + apiClient = ApiClient(baseUrl: 'http://test.local/api/v1', httpClient: httpClient); + }); + + testWidgets('Suche filtert nach Name und Code', (tester) async { + when(() => httpClient.get(any(), headers: any(named: 'headers'))).thenAnswer( + (_) async => http.Response( + jsonEncode([ + {'id': 1, 'name': 'Rucksack Handball', 'code': 'RK-00001', 'status': 'aktiv'}, + {'id': 2, 'name': 'Fahrzeug 2', 'code': 'FZ-00002', 'status': 'aktiv'}, + ]), + 200, + ), + ); + + await tester.pumpWidget(MaterialApp(home: ObjektListScreen(apiClient: apiClient))); + await tester.pumpAndSettle(); + + expect(find.text('Rucksack Handball'), findsOneWidget); + expect(find.text('Fahrzeug 2'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, 'FZ-00002'); + await tester.pumpAndSettle(); + + expect(find.text('Rucksack Handball'), findsNothing); + expect(find.text('Fahrzeug 2'), findsOneWidget); + }); +}