From a5215367fd16dc9ad5ce80de35c0da003bf7034d Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Fri, 25 Sep 2026 16:53:37 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20gespeicherte=20Fahrzeugauswahl=20gegen?= =?UTF-8?q?=20Server-Fahrzeuge=20pr=C3=BCfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Tagesauswahl liegt lokal pro Fahrer und wurde ungeprüft wiederhergestellt. Durch den alten Session-Fehler hatte 414 auf dem Gerät das Fahrzeug von 423 gespeichert — das erschien weiter, obwohl „Fahrzeug verwalten" (frisch vom Server) leer war. CarSelectBloc lädt beim Wiederherstellen die Fahrzeuge des Accounts vom Server und übernimmt die Auswahl nur, wenn das Fahrzeug dazugehört und aktiv ist; sonst wird sie verworfen und neu abgefragt. Co-Authored-By: Claude Opus 5.5 --- lib/feature/car_selection/bloc/bloc.dart | 39 +++++----- .../repository/car_selection_repository.dart | 9 +++ lib/widget/session_scope.dart | 1 + test/car_selection_bloc_test.dart | 74 +++++++++++++++++++ test/session_scope_test.dart | 10 +++ 5 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 test/car_selection_bloc_test.dart diff --git a/lib/feature/car_selection/bloc/bloc.dart b/lib/feature/car_selection/bloc/bloc.dart index c027e32..b58d58e 100644 --- a/lib/feature/car_selection/bloc/bloc.dart +++ b/lib/feature/car_selection/bloc/bloc.dart @@ -2,7 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; import 'package:hl_lieferservice/feature/cars/model/selection.dart'; -import 'package:hl_lieferservice/domain/entity/car.dart'; +import 'package:hl_lieferservice/domain/repository/cars_repository.dart'; import 'events.dart'; import 'state.dart'; @@ -10,7 +10,12 @@ import 'state.dart'; class CarSelectBloc extends Bloc { final CarSelectionRepository repository; - CarSelectBloc({required this.repository}) : super(CarSelectInitial()) { + /// Fahrzeuge des angemeldeten Accounts vom Server — gegen sie wird eine + /// gespeicherte Tagesauswahl geprüft. + final CarsRepository carsRepository; + + CarSelectBloc({required this.repository, required this.carsRepository}) + : super(CarSelectInitial()) { on(_load); on(_confirm); on(_change); @@ -36,23 +41,21 @@ class CarSelectBloc extends Bloc { stored.date.day == today.day; if (validForToday) { - emit( - CarSelectComplete( - selectedCar: Car( - id: stored.selectedCarId!, - // accountId/active fließen aus der lokalen Selection - // nicht durch — wir persistieren nur (id, plate) als - // UI-Pointer. Die Tour-Logik holt die vollständigen - // Car-Felder weiterhin aus dem CarsBloc-Listing. - accountId: 0, - plate: stored.selectedCarPlate!, - active: true, - ), - ), - ); - } else { - emit(CarSelectRequired()); + // Die lokale Auswahl ist nur ein Zeiger. Sie gilt nur, wenn das + // Fahrzeug laut Server (frisch geladen) zu diesem Account gehört + // und aktiv ist — sonst z. B. ein Fahrzeug eines anderen Fahrers, + // das auf diesem Gerät gewählt wurde. + final ownCars = await carsRepository.listMine(); + final car = ownCars + .where((c) => c.id == stored.selectedCarId && c.active) + .firstOrNull; + if (car != null) { + emit(CarSelectComplete(selectedCar: car)); + return; + } + await repository.clearSelection(event.userId); } + emit(CarSelectRequired()); } catch (e, st) { debugPrint('CarSelectBloc._load failed: $e'); debugPrint('Stacktrace: $st'); diff --git a/lib/feature/car_selection/repository/car_selection_repository.dart b/lib/feature/car_selection/repository/car_selection_repository.dart index 2cc265e..650b30e 100644 --- a/lib/feature/car_selection/repository/car_selection_repository.dart +++ b/lib/feature/car_selection/repository/car_selection_repository.dart @@ -49,6 +49,15 @@ class CarSelectionRepository { ); } + /// Verwirft die gespeicherte Auswahl (z. B. Fahrzeug gehört nicht mehr + /// zum Account) — der Fahrer muss neu wählen. + Future clearSelection(String userId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyDate(userId)); + await prefs.remove(_keyCarId(userId)); + await prefs.remove(_keyCarPlate(userId)); + } + Future saveSelection(String userId, CarSelection selection) async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/widget/session_scope.dart b/lib/widget/session_scope.dart index 9d89b05..df445e3 100644 --- a/lib/widget/session_scope.dart +++ b/lib/widget/session_scope.dart @@ -94,6 +94,7 @@ class _SessionScopeState extends State { repository: CarSelectionRepository( environmentId: widget.environmentId, ), + carsRepository: CarsRepositoryImpl(locator()), ), ), BlocProvider( diff --git a/test/car_selection_bloc_test.dart b/test/car_selection_bloc_test.dart new file mode 100644 index 0000000..da2ad26 --- /dev/null +++ b/test/car_selection_bloc_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:hl_lieferservice/domain/entity/car.dart'; +import 'package:hl_lieferservice/domain/repository/cars_repository.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart'; +import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart'; +import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart'; + +/// Fahrzeuge „laut Server" für den angemeldeten Account. +class _ServerCars implements CarsRepository { + _ServerCars(this.cars); + final List cars; + + @override + Future> listMine({bool includeInactive = false}) async => cars; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +const _fremdesAuto = 'b918b721-0000-0000-0000-000000000423'; + +Map _gespeichert(String userId, String carId) => { + 'flutter.car_selection_test_${userId}_date': DateTime.now().toIso8601String(), + 'flutter.car_selection_test_${userId}_car_id': carId, + 'flutter.car_selection_test_${userId}_car_plate': 'test', +}; + +Future _laden(String userId, List serverCars) async { + final bloc = CarSelectBloc( + repository: CarSelectionRepository(environmentId: 'test'), + carsRepository: _ServerCars(serverCars), + )..add(CarSelectLoad(userId: userId)); + final state = await bloc.stream.firstWhere( + (s) => s is! CarSelectLoading, + ); + await bloc.close(); + return state; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('gespeichertes Fahrzeug eines anderen Fahrers wird verworfen', () async { + // 414 hat auf diesem Gerät das Auto von 423 als Tagesfahrzeug gespeichert. + SharedPreferences.setMockInitialValues(_gespeichert('414', _fremdesAuto)); + + final state = await _laden('414', const []); + + expect(state, isA()); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('car_selection_test_414_car_id'), isNull); + }); + + test('eigenes aktives Fahrzeug wird übernommen, mit Serverdaten', () async { + SharedPreferences.setMockInitialValues(_gespeichert('423', _fremdesAuto)); + const auto = Car(id: _fremdesAuto, accountId: 423, plate: 'test', active: true); + + final state = await _laden('423', const [auto]); + + expect(state, isA()); + expect((state as CarSelectComplete).selectedCar.accountId, 423); + }); + + test('deaktiviertes Fahrzeug gilt nicht mehr', () async { + SharedPreferences.setMockInitialValues(_gespeichert('423', _fremdesAuto)); + const auto = + Car(id: _fremdesAuto, accountId: 423, plate: 'test', active: false); + + expect(await _laden('423', const [auto]), isA()); + }); +} diff --git a/test/session_scope_test.dart b/test/session_scope_test.dart index c86fa3e..236ae48 100644 --- a/test/session_scope_test.dart +++ b/test/session_scope_test.dart @@ -12,8 +12,10 @@ import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; +import 'package:hl_lieferservice/main.dart' show locator; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/session_scope.dart'; +import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi; /// Token-Provider ohne Keycloak: der Test steuert Login/Logout über [emit]. class _FakeTokenProvider implements KeycloakOidcTokenProvider { @@ -45,6 +47,14 @@ AuthLoggedIn _login(int personalnummer) => AuthLoggedIn({'personalnummer': personalnummer, 'name': 'Fahrer'}); void main() { + // Session-Blocs bauen ihre Repositories gegen den API-Client; im Test + // reicht ein unbenutzter Client (es wird nichts geladen). + setUpAll(() { + if (!locator.isRegistered()) { + locator.registerSingleton(HolzleitnerApi()); + } + }); + late _FakeTokenProvider provider; late _CountingPaymentMethods payments; late List carSelections;