fix: Fehlerbehandlung beim Laden und Meldungen überarbeiten
Reklamationen der Fahrer: Meldungen verdeckten Buttons, und nach dem Abschluss stand „Lieferung nicht in der Tour gefunden" ohne Ausweg da. Meldungen: - Erfolgs-SnackBars entfernt, bei Erfolg bleibt der Spinner - einheitliche, schwebende Meldungen statt Stau über den Buttons - Server-Rohtexte durch verständliche Texte ersetzt (api_failure_text) Laden: - fehlt eine geöffnete Lieferung, wird die Tour einmal neu geholt; die Meldung kommt nur nach erfolgreichem Abruf ohne die Lieferung - Endlos-Spinner auf Lieferdetail, Filial-Abholung und Beladen ersetzt durch Kopfzeile, Fehlergrund und „Erneut versuchen" - Tour wird nach einem Tageswechsel nachgeladen, offene Seiten bleiben dabei unberührt - Abschluss und Zuweisung überschreiben kein zwischenzeitliches Neuladen, alte Antworten drehen einen Abschluss nicht mehr zurück - leere Server-Antworten gelten als Fehler statt als „keine Daten" Anmeldung: - Token-Erneuerung wartet höchstens 12 s, der native Aufruf läuft weiter - App-Start ohne Netz zeigt einen Hinweis mit „Erneut versuchen" - Fehlerseite der Fahrzeugauswahl bietet „Abmelden" Außerdem Podfile.lock an die Plugins angeglichen. Tests für die neuen Fehlerpfade ergänzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
642
test/load_failure_test.dart
Normal file
642
test/load_failure_test.dart
Normal file
@ -0,0 +1,642 @@
|
||||
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<Delivery> 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<Object?> answers;
|
||||
|
||||
@override
|
||||
Future<TourDetails?> getMyTourDetails({DateTime? date}) {
|
||||
final next = answers.removeAt(0);
|
||||
if (next is Completer<TourDetails?>) return next.future;
|
||||
if (next is Exception) return Future.error(next);
|
||||
return Future.value(next as TourDetails?);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Delivery> 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<void> retainOnly(Set<String> ids) async {}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
TourBloc _bloc(List<Object?> 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<void> _settle() => Future<void>.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<TourDetails?>();
|
||||
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<void> 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<void>(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<void> finish(WidgetTester tester) =>
|
||||
tester.pump(const Duration(seconds: 6));
|
||||
|
||||
for (final entry in <String, Widget>{
|
||||
'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<void> 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<void>(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<void> 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<Object>(requestOptions: options, statusCode: 200),
|
||||
),
|
||||
),
|
||||
);
|
||||
return HolzleitnerApi(dio: dio, interceptors: const []);
|
||||
}
|
||||
|
||||
test('Tour', () {
|
||||
expect(
|
||||
TourRepositoryImpl(emptyApi()).getMyTourSummary(),
|
||||
throwsA(isA<TourRepositoryException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Fahrzeuge', () {
|
||||
expect(
|
||||
CarsRepositoryImpl(emptyApi()).listMine(),
|
||||
throwsA(isA<CarsRepositoryException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Zahlungsmethoden', () {
|
||||
expect(
|
||||
PaymentMethodsRepositoryImpl(emptyApi()).list(),
|
||||
throwsA(isA<PaymentMethodsRepositoryException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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<AuthTemporarilyUnavailableException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<AuthBootstrapping>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _CountingTours extends _ScriptedTours {
|
||||
_CountingTours() : super([]);
|
||||
|
||||
int loads = 0;
|
||||
|
||||
@override
|
||||
Future<TourDetails?> 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<TourBloc, TourState>(
|
||||
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<Delivery>();
|
||||
|
||||
@override
|
||||
Future<Delivery> cancelDelivery({
|
||||
required String deliveryId,
|
||||
required String reason,
|
||||
}) => cancel.future;
|
||||
}
|
||||
|
||||
class _HangingAppAuth implements FlutterAppAuth {
|
||||
final _result = Completer<TokenResponse>();
|
||||
int calls = 0;
|
||||
|
||||
void finish(TokenResponse response) => _result.complete(response);
|
||||
|
||||
@override
|
||||
Future<TokenResponse> 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<String?>.value('rt');
|
||||
if (invocation.memberName == #write) return Future<void>.value();
|
||||
return super.noSuchMethod(invocation);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScriptedRestore implements KeycloakOidcTokenProvider {
|
||||
_ScriptedRestore(this.results);
|
||||
|
||||
final List<SessionRestoreResult> results;
|
||||
|
||||
@override
|
||||
Stream<AuthSessionEvent> get events => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<SessionRestoreResult> restoreSession() async => results.removeAt(0);
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
Reference in New Issue
Block a user