import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; import 'package:hl_lieferservice/data/cache/attachment_cache.dart'; import 'package:hl_lieferservice/data/network/auth_session_event.dart'; import 'package:hl_lieferservice/data/network/auth_token_provider.dart'; import 'package:hl_lieferservice/data/network/backend_config.dart'; import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart'; import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart'; import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart'; import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart'; import 'package:hl_lieferservice/domain/entity/address.dart'; import 'package:hl_lieferservice/domain/entity/delivery.dart'; import 'package:hl_lieferservice/domain/entity/tour.dart'; import 'package:hl_lieferservice/domain/entity/tour_details.dart'; import 'package:hl_lieferservice/domain/repository/cars_repository.dart'; import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart'; import 'package:hl_lieferservice/domain/repository/tour_repository.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart'; import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart'; import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart'; import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart'; import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; const _gone = 'Diese Lieferung ist in der geladenen Tour nicht enthalten. ' 'Bitte das Tour-Datum prüfen.'; const _offline = 'Server nicht erreichbar. Bitte Verbindung prüfen'; Delivery _delivery(String id, {DeliveryState state = DeliveryState.active}) => Delivery( id: id, tourId: 't1', customerId: 'c1', contactPersonIds: const [], deliveryAddressSnapshot: const Address( street: 'Konstantinplatz', houseNumber: '3', postalCode: '41849', city: 'Wassenberg', country: 'DE', ), erpBelegartId: 24, erpBelegnummer: 'V-$id', state: state, sortOrder: 1, prepaidAmount: 0, paymentMethodId: 'm-cash', items: const [], ); TourDetails _tour(List deliveries) => TourDetails( tour: Tour( id: 't1', accountId: 414, date: DateTime(2026, 10, 30), syncedAt: DateTime(2026, 10, 30), ), deliveries: deliveries, customers: const {}, contacts: const {}, articles: const {}, warehouses: const {}, notesByDeliveryId: const {}, creditsByDeliveryId: const {}, paymentsByDeliveryId: const {}, services: const [], serviceValuesByDeliveryId: const {}, contactSourcesByDeliveryId: const {}, contactChannelsBySourceId: const {}, ); /// Antwortet der Reihe nach mit den vorbereiteten Ergebnissen. Ein /// [Completer] bleibt offen, bis der Test ihn abschließt. class _ScriptedTours implements TourRepository { _ScriptedTours(this.answers); final List answers; @override Future getMyTourDetails({DateTime? date}) { final next = answers.removeAt(0); if (next is Completer) return next.future; if (next is Exception) return Future.error(next); return Future.value(next as TourDetails?); } @override Future cancelDelivery({ required String deliveryId, required String reason, }) async => _delivery(deliveryId, state: DeliveryState.canceled); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _NoCache implements AttachmentCache { @override Future retainOnly(Set ids) async {} @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } TourBloc _bloc(List answers) => TourBloc( tourRepository: _ScriptedTours(answers), opBloc: OperationBloc(), attachmentCache: _NoCache(), ); const _networkDown = TourRepositoryException(_offline); /// Lässt die Bloc-Handler laufen, ohne auf ein festes Ereignis zu warten. Future _settle() => Future.delayed(const Duration(milliseconds: 5)); void main() { group('TourBloc bei Verbindungsfehlern', () { test('Neuladen scheitert: die alte Tour bleibt mit Fehlertext stehen', () async { final bloc = _bloc([ _tour([_delivery('d1')]), _networkDown, ]); bloc.add(const LoadTour()); await _settle(); bloc.add(const RefreshTour()); await _settle(); final state = bloc.state as TourLoaded; expect(state.details.deliveries.single.id, 'd1'); expect(state.refreshError, _offline); expect(state.isRefreshing, isFalse); }); test('erstes Laden scheitert: Fehlerzustand mit dem Grund', () async { final bloc = _bloc([_networkDown]); bloc.add(const LoadTour()); await _settle(); expect((bloc.state as TourLoadFailed).message, _offline); }); test('alte Antwort eines Neuladens dreht einen Abbruch nicht zurück', () async { final slow = Completer(); final bloc = _bloc([ _tour([_delivery('d1')]), slow, ]); bloc.add(const LoadTour()); await _settle(); bloc.add(const RefreshTour()); await _settle(); bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt')); await _settle(); // Die Antwort kam vom Server, bevor er den Abbruch kannte. slow.complete(_tour([_delivery('d1')])); await _settle(); final state = bloc.state as TourLoaded; expect(state.details.deliveries.single.state, DeliveryState.canceled); expect(state.isRefreshing, isFalse); }); test('Abbruch überschreibt kein zwischenzeitlich fertiges Neuladen', () async { final repo = _SlowCancelTours( _tour([_delivery('d1')]), _tour([_delivery('d1'), _delivery('neu')]), ); final bloc = TourBloc( tourRepository: repo, opBloc: OperationBloc(), attachmentCache: _NoCache(), ); bloc.add(const LoadTour()); await _settle(); bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt')); await _settle(); bloc.add(const RefreshTour()); await _settle(); repo.cancel.complete(_delivery('d1', state: DeliveryState.canceled)); await _settle(); final deliveries = (bloc.state as TourLoaded).details.deliveries; expect(deliveries.map((d) => d.id), ['d1', 'neu']); expect(deliveries.first.state, DeliveryState.canceled); }); }); group('Unterseiten einer Lieferung', () { /// Öffnet [page] über einer Startseite, wie aus der Übersicht heraus. Future open( WidgetTester tester, TourBloc bloc, Widget page, ) async { await tester.pumpWidget( BlocProvider.value( value: bloc, child: MaterialApp( home: Builder( builder: (context) => Scaffold( body: TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => page), ), child: const Text('Übersicht'), ), ), ), ), ), ); await tester.tap(find.text('Übersicht')); await tester.pumpAndSettle(); } /// `FailOperation` hält seinen Zustand fünf Sekunden; danach darf im Test /// kein Timer mehr offen sein. Future finish(WidgetTester tester) => tester.pump(const Duration(seconds: 6)); for (final entry in { 'Lieferdetail': const DeliveryDetail(deliveryId: 'd1'), 'Filial-Abholung': const FilialePickupScanPage(deliveryId: 'd1'), }.entries) { testWidgets( '${entry.key}: Fehlschlag zeigt Grund, Neuladen und Zurück, ' 'und fehlt die Lieferung auch im zweiten Abruf, geht es zur Übersicht', (tester) async { final bloc = _bloc([ _networkDown, _tour(const []), _tour(const []), ]); addTearDown(bloc.close); await tester.runAsync(() async { bloc.add(const LoadTour()); await _settle(); }); await open(tester, bloc, entry.value); expect(find.text(_offline), findsOneWidget); expect(find.byType(BackButton), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsNothing); await tester.tap(find.text('Erneut versuchen')); await tester.runAsync(_settle); await tester.pump(); await tester.pumpAndSettle(); expect(find.text('Übersicht'), findsOneWidget); expect(find.text(_gone), findsOneWidget); expect(find.textContaining('nicht in der Tour'), findsNothing); await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); await finish(tester); }); testWidgets('${entry.key}: Server meldet keine Tour, zurück zur Übersicht', (tester) async { final bloc = _bloc([ _networkDown, null, null, ]); addTearDown(bloc.close); await tester.runAsync(() async { bloc.add(const LoadTour()); await _settle(); }); await open(tester, bloc, entry.value); await tester.tap(find.text('Erneut versuchen')); await tester.runAsync(_settle); await tester.pump(); await tester.pumpAndSettle(); expect(find.text('Übersicht'), findsOneWidget); expect(find.text(_gone), findsOneWidget); await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); await finish(tester); }); } }); group('fehlende Lieferung: erst neu laden, dann melden', () { late _CountingTours tours; late TourBloc bloc; Future pumpGuarded(WidgetTester tester) async { tours = _CountingTours(); bloc = TourBloc( tourRepository: tours, opBloc: OperationBloc(), attachmentCache: _NoCache(), ); tours.answers.add(_tour([_delivery('d1')])); await tester.runAsync(() async { bloc.add(const LoadTour()); await _settle(); }); await tester.pumpWidget( BlocProvider.value( value: bloc, child: MaterialApp( home: Builder( builder: (context) => Scaffold( body: TextButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const _GuardedPage()), ), child: const Text('Übersicht'), ), ), ), ), ), ); await tester.tap(find.text('Übersicht')); await tester.pumpAndSettle(); expect(find.text('Lieferung sichtbar'), findsOneWidget); } /// Ein Neuladen liefert die vorbereiteten Antworten der Reihe nach. Future reload(WidgetTester tester) async { bloc.add(const RefreshTour()); await tester.runAsync(_settle); await tester.pump(); await tester.runAsync(_settle); await tester.pump(); await tester.pump(const Duration(seconds: 1)); } testWidgets('zweiter Abruf enthält sie: keine Meldung, Seite bleibt', (tester) async { await pumpGuarded(tester); tours.answers ..add(_tour(const [])) ..add(_tour([_delivery('d1')])); await reload(tester); expect(tours.loads, 3, reason: 'genau ein zusätzlicher Abruf'); expect(find.text('Lieferung sichtbar'), findsOneWidget); expect(find.text(_gone), findsNothing); }); testWidgets('zweiter Abruf enthält sie nicht: erst dann die Meldung', (tester) async { await pumpGuarded(tester); tours.answers ..add(_tour(const [])) ..add(_tour(const [])); await reload(tester); expect(tours.loads, 3, reason: 'kein dritter Versuch'); expect(find.text(_gone), findsOneWidget); expect(find.text('Übersicht'), findsOneWidget); await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); }); testWidgets('zweiter Abruf scheitert: keine Meldung, Fehler und Wiederholung', (tester) async { await pumpGuarded(tester); tours.answers ..add(_tour(const [])) ..add(_networkDown); await reload(tester); expect(find.text(_gone), findsNothing); expect(find.text(_offline), findsOneWidget); expect(find.text('Erneut versuchen'), findsOneWidget); // Netz ist wieder da und die Lieferung gehört zur Tour. tours.answers.add(_tour([_delivery('d1')])); await tester.tap(find.text('Erneut versuchen')); await tester.runAsync(_settle); await tester.pump(); expect(find.text('Lieferung sichtbar'), findsOneWidget); expect(find.text(_gone), findsNothing); }); testWidgets('später erneut verschwunden: wieder ein zusätzlicher Abruf', (tester) async { await pumpGuarded(tester); tours.answers ..add(_tour(const [])) ..add(_tour([_delivery('d1')])); await reload(tester); tours.answers ..add(_tour(const [])) ..add(_tour([_delivery('d1')])); await reload(tester); expect(tours.loads, 5); expect(find.text('Lieferung sichtbar'), findsOneWidget); }); }); group('leere Server-Antworten sind Fehler', () { HolzleitnerApi emptyApi() { final dio = Dio() ..interceptors.add( InterceptorsWrapper( onRequest: (options, handler) => handler.resolve( Response(requestOptions: options, statusCode: 200), ), ), ); return HolzleitnerApi(dio: dio, interceptors: const []); } test('Tour', () { expect( TourRepositoryImpl(emptyApi()).getMyTourSummary(), throwsA(isA()), ); }); test('Fahrzeuge', () { expect( CarsRepositoryImpl(emptyApi()).listMine(), throwsA(isA()), ); }); test('Zahlungsmethoden', () { expect( PaymentMethodsRepositoryImpl(emptyApi()).list(), throwsA(isA()), ); }); }); group('Token-Erneuerung', () { KeycloakOidcTokenProvider provider(_HangingAppAuth appAuth) => KeycloakOidcTokenProvider( config: const BackendConfig( apiBaseUrl: 'http://localhost', keycloakIssuerUrl: 'http://localhost/realms/x', keycloakClientId: 'app', keycloakRedirectUrl: 'holzleitner://oauth2redirect', ), issuerUrl: 'http://localhost/realms/x', redirectUrl: 'holzleitner://oauth2redirect', appAuth: appAuth, storage: _StoredRefreshToken(), refreshTimeout: const Duration(milliseconds: 10), ); test('hängender Refresh hält den Request nicht auf', () async { final auth = provider(_HangingAppAuth()); expect( await auth.restoreSession(), SessionRestoreResult.temporarilyUnavailable, ); await expectLater( auth.currentAccessToken(), throwsA(isA()), ); }); test('später Abschluss wird übernommen, ohne zweiten Refresh', () async { final appAuth = _HangingAppAuth(); final auth = provider(appAuth); await auth.restoreSession(); expect(appAuth.calls, 1); appAuth.finish( TokenResponse( 'access-neu', 'refresh-neu', DateTime.now().add(const Duration(hours: 1)), null, null, null, null, ), ); await _settle(); expect(await auth.currentAccessToken(), 'access-neu'); expect(appAuth.calls, 1); }); }); group('Sitzung beim App-Start', () { AuthBloc authBloc(_ScriptedRestore provider) => AuthBloc(tokenProvider: provider, operationBloc: OperationBloc()); test('ohne Verbindung: Login-Seite mit Hinweis, Session bleibt', () async { final bloc = authBloc( _ScriptedRestore([SessionRestoreResult.temporarilyUnavailable]), ); bloc.add(const RestoreSessionRequested()); await _settle(); expect((bloc.state as Unauthenticated).restoreUnavailable, isTrue); }); test('ohne Session: normale Login-Seite ohne Hinweis', () async { final bloc = authBloc(_ScriptedRestore([SessionRestoreResult.noSession])); bloc.add(const RestoreSessionRequested()); await _settle(); expect((bloc.state as Unauthenticated).restoreUnavailable, isFalse); }); test('erneuter Versuch zeigt wieder den Start-Bildschirm', () async { final bloc = authBloc( _ScriptedRestore([ SessionRestoreResult.temporarilyUnavailable, SessionRestoreResult.restored, ]), ); bloc.add(const RestoreSessionRequested()); await _settle(); bloc.add(const RestoreSessionRequested()); await _settle(); expect(bloc.state, isA()); }); }); } class _CountingTours extends _ScriptedTours { _CountingTours() : super([]); int loads = 0; @override Future getMyTourDetails({DateTime? date}) { loads++; return super.getMyTourDetails(date: date); } } /// Kleinstmögliche Seite mit dem Guard. Die echten Seiten brauchen für eine /// vorhandene Lieferung den ganzen Bloc-Baum. class _GuardedPage extends StatefulWidget { const _GuardedPage(); @override State<_GuardedPage> createState() => _GuardedPageState(); } class _GuardedPageState extends State<_GuardedPage> with DeliveryPresenceGuard { @override String get guardedDeliveryId => 'd1'; @override Widget build(BuildContext context) { return BlocConsumer( listener: deliveryStillPresent, builder: (context, state) { final present = state is TourLoaded && state.details.deliveries.any((d) => d.id == guardedDeliveryId); if (!present) { return TourNotLoadedScaffold(title: 'Lieferung', state: state); } return const Scaffold(body: Text('Lieferung sichtbar')); }, ); } } class _SlowCancelTours extends _ScriptedTours { _SlowCancelTours(TourDetails first, TourDetails second) : super([first, second]); final cancel = Completer(); @override Future cancelDelivery({ required String deliveryId, required String reason, }) => cancel.future; } class _HangingAppAuth implements FlutterAppAuth { final _result = Completer(); int calls = 0; void finish(TokenResponse response) => _result.complete(response); @override Future token(TokenRequest request) { calls++; return _result.future; } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _StoredRefreshToken implements FlutterSecureStorage { @override dynamic noSuchMethod(Invocation invocation) { if (invocation.memberName == #read) return Future.value('rt'); if (invocation.memberName == #write) return Future.value(); return super.noSuchMethod(invocation); } } class _ScriptedRestore implements KeycloakOidcTokenProvider { _ScriptedRestore(this.results); final List results; @override Stream get events => const Stream.empty(); @override Future restoreSession() async => results.removeAt(0); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }