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;