import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:unionflow_mobile_apps/features/onboarding/bloc/onboarding_bloc.dart'; import 'package:unionflow_mobile_apps/features/onboarding/data/datasources/souscription_datasource.dart'; import 'package:unionflow_mobile_apps/features/onboarding/data/models/formule_model.dart'; import 'package:unionflow_mobile_apps/features/onboarding/data/models/souscription_status_model.dart'; @GenerateMocks([SouscriptionDatasource]) import 'onboarding_bloc_test.mocks.dart'; // ─── Fixtures ──────────────────────────────────────────────────────────────── List _formules() => [ const FormuleModel( code: 'BASIC', libelle: 'Basic', plage: 'PETITE', plageLibelle: '1-20 membres', minMembres: 1, maxMembres: 20, prixMensuel: 5000, prixAnnuel: 50000, ordreAffichage: 1, ), const FormuleModel( code: 'STANDARD', libelle: 'Standard', plage: 'MOYENNE', plageLibelle: '21-100 membres', minMembres: 21, maxMembres: 100, prixMensuel: 15000, prixAnnuel: 150000, ordreAffichage: 2, ), ]; SouscriptionStatusModel _souscription({String? waveLaunchUrl}) => SouscriptionStatusModel( souscriptionId: 'sosc-1', statutValidation: 'EN_ATTENTE_PAIEMENT', typeFormule: 'BASIC', plageMembres: 'PETITE', plageLibelle: '1-20 membres', typePeriode: 'MENSUEL', typeOrganisation: 'ASSOCIATION', montantTotal: 5000, waveLaunchUrl: waveLaunchUrl, organisationId: 'org-1', ); // ─── Tests ─────────────────────────────────────────────────────────────────── void main() { late OnboardingBloc bloc; late MockSouscriptionDatasource mockDatasource; setUp(() { mockDatasource = MockSouscriptionDatasource(); bloc = OnboardingBloc(mockDatasource); }); tearDown(() => bloc.close()); // ─── Initial state ────────────────────────────────────────────────────────── test('initial state is OnboardingInitial', () { expect(bloc.state, isA()); }); // ─── OnboardingStarted ────────────────────────────────────────────────────── group('OnboardingStarted — NO_SUBSCRIPTION', () { blocTest( 'emits [OnboardingLoading, OnboardingStepFormule] with loaded formulas', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); return bloc; }, act: (b) => b.add(const OnboardingStarted( initialState: 'NO_SUBSCRIPTION', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )), expect: () => [ isA(), isA() .having((s) => s.formules.length, 'formules count', 2), ], ); blocTest( 'emits [OnboardingLoading, OnboardingError] when getFormules throws', build: () { when(mockDatasource.getFormules()) .thenThrow(Exception('Network error')); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'NO_SUBSCRIPTION')), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Impossible de charger'), ), ], ); blocTest( 'falls back to OnboardingStepFormule for unknown initialState', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'UNKNOWN_STATE')), expect: () => [isA(), isA()], ); }); group('OnboardingStarted — AWAITING_PAYMENT', () { blocTest( 'emits OnboardingStepSummary when getMaSouscription returns a souscription', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'AWAITING_PAYMENT')), expect: () => [ isA(), isA().having( (s) => s.souscription.souscriptionId, 'souscriptionId', 'sosc-1', ), ], ); blocTest( 'falls back to OnboardingStepFormule when getMaSouscription returns null', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => null); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'AWAITING_PAYMENT')), expect: () => [isA(), isA()], ); }); group('OnboardingStarted — PAYMENT_INITIATED', () { blocTest( 'emits OnboardingStepPaiement when souscription has waveLaunchUrl', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()).thenAnswer( (_) async => _souscription(waveLaunchUrl: 'https://wave.com/pay/xyz'), ); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'PAYMENT_INITIATED')), expect: () => [ isA(), isA() .having((s) => s.waveLaunchUrl, 'waveLaunchUrl', 'https://wave.com/pay/xyz'), ], ); blocTest( 'emits OnboardingStepAttente when souscription has no waveLaunchUrl', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); // no waveLaunchUrl return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'PAYMENT_INITIATED')), expect: () => [isA(), isA()], ); }); group('OnboardingStarted — AWAITING_VALIDATION', () { blocTest( 'emits OnboardingStepAttente with the fetched souscription', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'AWAITING_VALIDATION')), expect: () => [ isA(), isA().having( (s) => s.souscription?.souscriptionId, 'souscriptionId', 'sosc-1', ), ], ); }); group('OnboardingStarted — VALIDATED', () { blocTest( 'emits OnboardingStepAttente for edge case where activation not yet effective', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'VALIDATED')), expect: () => [isA(), isA()], ); }); group('OnboardingStarted — REJECTED', () { blocTest( 'emits OnboardingRejected with commentaire from statutValidation', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()).thenAnswer((_) async => SouscriptionStatusModel( souscriptionId: 'sosc-2', statutValidation: 'Documents manquants', typeFormule: 'BASIC', plageMembres: 'PETITE', plageLibelle: '1-20', typePeriode: 'MENSUEL', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )); return bloc; }, act: (b) => b.add(const OnboardingStarted(initialState: 'REJECTED')), expect: () => [ isA(), isA() .having((s) => s.commentaire, 'commentaire', 'Documents manquants'), ], ); }); // ─── OnboardingFormuleSelected ───────────────────────────────────────────── group('OnboardingFormuleSelected', () { blocTest( 'emits OnboardingStepPeriode with selected formule and plage', build: () { // Pre-load formulas by starting first when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); return bloc; }, seed: () => OnboardingStepFormule(_formules()), act: (b) => b.add(const OnboardingFormuleSelected( codeFormule: 'BASIC', plage: 'PETITE', )), expect: () => [ isA() .having((s) => s.codeFormule, 'codeFormule', 'BASIC') .having((s) => s.plage, 'plage', 'PETITE'), ], ); blocTest( 'emits OnboardingStepPeriode with STANDARD/GRANDE', build: () => bloc, seed: () => OnboardingStepFormule(_formules()), act: (b) => b.add(const OnboardingFormuleSelected( codeFormule: 'STANDARD', plage: 'GRANDE', )), expect: () => [ isA() .having((s) => s.codeFormule, 'codeFormule', 'STANDARD') .having((s) => s.plage, 'plage', 'GRANDE'), ], ); }); // ─── OnboardingPeriodeSelected ───────────────────────────────────────────── group('OnboardingPeriodeSelected', () { blocTest( 'does not emit a new state (only updates internal data)', build: () => bloc, act: (b) => b.add(const OnboardingPeriodeSelected( typePeriode: 'MENSUEL', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )), expect: () => [], ); }); // ─── OnboardingDemandeConfirmee ──────────────────────────────────────────── group('OnboardingDemandeConfirmee', () { blocTest( 'emits OnboardingError when required data is missing', build: () => bloc, // No formule, plage, periode, organisationId set → missing data act: (b) => b.add(const OnboardingDemandeConfirmee()), expect: () => [ isA().having( (s) => s.message, 'message', contains('Données manquantes'), ), ], ); blocTest( 'emits [OnboardingLoading, OnboardingStepSummary] on successful creation', build: () { when(mockDatasource.creerDemande( typeFormule: anyNamed('typeFormule'), plageMembres: anyNamed('plageMembres'), typePeriode: anyNamed('typePeriode'), typeOrganisation: anyNamed('typeOrganisation'), organisationId: anyNamed('organisationId'), )).thenAnswer((_) async => _souscription()); return bloc; }, // Seed internal state: first select formule, then periode act: (b) async { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); b.add(const OnboardingStarted( initialState: 'NO_SUBSCRIPTION', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingFormuleSelected( codeFormule: 'BASIC', plage: 'PETITE', )); b.add(const OnboardingPeriodeSelected( typePeriode: 'MENSUEL', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )); b.add(const OnboardingDemandeConfirmee()); }, expect: () => [ isA(), // from OnboardingStarted isA(), // after getFormules isA(), // after OnboardingFormuleSelected // OnboardingPeriodeSelected emits nothing isA(), // from OnboardingDemandeConfirmee isA() .having((s) => s.souscription.souscriptionId, 'id', 'sosc-1'), ], ); blocTest( 'emits [OnboardingLoading, OnboardingError] when creerDemande returns null', build: () { when(mockDatasource.creerDemande( typeFormule: anyNamed('typeFormule'), plageMembres: anyNamed('plageMembres'), typePeriode: anyNamed('typePeriode'), typeOrganisation: anyNamed('typeOrganisation'), organisationId: anyNamed('organisationId'), )).thenAnswer((_) async => null); return bloc; }, act: (b) async { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); b.add(const OnboardingStarted( initialState: 'NO_SUBSCRIPTION', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingFormuleSelected( codeFormule: 'BASIC', plage: 'PETITE', )); b.add(const OnboardingPeriodeSelected( typePeriode: 'MENSUEL', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )); b.add(const OnboardingDemandeConfirmee()); }, expect: () => [ isA(), isA(), isA(), isA(), isA().having( (s) => s.message, 'message', contains('Erreur lors de la création'), ), ], ); blocTest( 'emits [OnboardingLoading, OnboardingError] when creerDemande throws', build: () { when(mockDatasource.creerDemande( typeFormule: anyNamed('typeFormule'), plageMembres: anyNamed('plageMembres'), typePeriode: anyNamed('typePeriode'), typeOrganisation: anyNamed('typeOrganisation'), organisationId: anyNamed('organisationId'), )).thenThrow(Exception('API unavailable')); return bloc; }, act: (b) async { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); b.add(const OnboardingStarted( initialState: 'NO_SUBSCRIPTION', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingFormuleSelected( codeFormule: 'BASIC', plage: 'PETITE', )); b.add(const OnboardingPeriodeSelected( typePeriode: 'MENSUEL', typeOrganisation: 'ASSOCIATION', organisationId: 'org-1', )); b.add(const OnboardingDemandeConfirmee()); }, expect: () => [ isA(), isA(), isA(), isA(), isA(), ], ); }); // ─── OnboardingChoixPaiementOuvert ───────────────────────────────────────── group('OnboardingChoixPaiementOuvert', () { blocTest( 'emits OnboardingStepChoixPaiement when souscription is loaded', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); return bloc; }, act: (b) async { b.add(const OnboardingStarted(initialState: 'AWAITING_PAYMENT')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingChoixPaiementOuvert()); }, expect: () => [ isA(), isA(), isA().having( (s) => s.souscription.souscriptionId, 'souscriptionId', 'sosc-1', ), ], ); blocTest( 'does nothing when no souscription is loaded', build: () => bloc, act: (b) => b.add(const OnboardingChoixPaiementOuvert()), expect: () => [], ); }); // ─── OnboardingPaiementInitie ────────────────────────────────────────────── group('OnboardingPaiementInitie', () { blocTest( 'emits OnboardingError when no souscription loaded', build: () => bloc, act: (b) => b.add(const OnboardingPaiementInitie()), expect: () => [ isA().having( (s) => s.message, 'message', contains('Souscription introuvable'), ), ], ); blocTest( 'emits [OnboardingLoading, OnboardingStepPaiement] when initierPaiement succeeds', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); when(mockDatasource.initierPaiement('sosc-1')).thenAnswer( (_) async => _souscription(waveLaunchUrl: 'https://wave.com/pay/abc'), ); return bloc; }, act: (b) async { b.add(const OnboardingStarted(initialState: 'AWAITING_PAYMENT')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingPaiementInitie()); }, expect: () => [ isA(), isA(), isA(), isA() .having((s) => s.waveLaunchUrl, 'waveLaunchUrl', 'https://wave.com/pay/abc'), ], ); blocTest( 'emits [OnboardingLoading, OnboardingError] when initierPaiement returns null', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); when(mockDatasource.initierPaiement('sosc-1')) .thenAnswer((_) async => null); return bloc; }, act: (b) async { b.add(const OnboardingStarted(initialState: 'AWAITING_PAYMENT')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingPaiementInitie()); }, expect: () => [ isA(), isA(), isA(), isA().having( (s) => s.message, 'message', contains('paiement Wave'), ), ], ); blocTest( 'emits [OnboardingLoading, OnboardingError] when initierPaiement throws', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription()); when(mockDatasource.initierPaiement('sosc-1')) .thenThrow(Exception('Wave timeout')); return bloc; }, act: (b) async { b.add(const OnboardingStarted(initialState: 'AWAITING_PAYMENT')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingPaiementInitie()); }, expect: () => [ isA(), isA(), isA(), isA(), ], ); }); // ─── OnboardingRetourDepuisWave ──────────────────────────────────────────── group('OnboardingRetourDepuisWave', () { blocTest( 'emits [OnboardingLoading, OnboardingError] when no souscription loaded', build: () => bloc, act: (b) => b.add(const OnboardingRetourDepuisWave()), expect: () => [ isA(), isA().having( (s) => s.message, 'message', contains('Souscription introuvable'), ), ], ); blocTest( 'emits [OnboardingLoading, OnboardingPaiementConfirme] when confirmerPaiement returns true', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()) .thenAnswer((_) async => _souscription(waveLaunchUrl: 'https://wave.com/pay/abc')); when(mockDatasource.initierPaiement('sosc-1')).thenAnswer( (_) async => _souscription(waveLaunchUrl: 'https://wave.com/pay/abc'), ); when(mockDatasource.confirmerPaiement('sosc-1')) .thenAnswer((_) async => true); return bloc; }, act: (b) async { b.add(const OnboardingStarted(initialState: 'PAYMENT_INITIATED')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingRetourDepuisWave()); }, expect: () => [ isA(), isA(), isA(), isA(), ], ); blocTest( 'emits [OnboardingLoading, OnboardingPaiementEchoue] when confirmerPaiement returns false', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()).thenAnswer( (_) async => _souscription(waveLaunchUrl: 'https://wave.com/pay/abc'), ); when(mockDatasource.confirmerPaiement('sosc-1')) .thenAnswer((_) async => false); return bloc; }, act: (b) async { b.add(const OnboardingStarted(initialState: 'PAYMENT_INITIATED')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingRetourDepuisWave()); }, expect: () => [ isA(), isA(), isA(), isA().having( (s) => s.message, 'message', contains('confirmé'), ), ], ); blocTest( 'emits [OnboardingLoading, OnboardingPaiementEchoue] when confirmerPaiement throws', build: () { when(mockDatasource.getFormules()) .thenAnswer((_) async => _formules()); when(mockDatasource.getMaSouscription()).thenAnswer( (_) async => _souscription(waveLaunchUrl: 'https://wave.com/pay/abc'), ); when(mockDatasource.confirmerPaiement('sosc-1')) .thenThrow(Exception('Timeout')); return bloc; }, act: (b) async { b.add(const OnboardingStarted(initialState: 'PAYMENT_INITIATED')); await Future.delayed(const Duration(milliseconds: 50)); b.add(const OnboardingRetourDepuisWave()); }, expect: () => [ isA(), isA(), isA(), isA().having( (s) => s.message, 'message', contains('Erreur lors de la confirmation'), ), ], ); }); // ─── State equality ──────────────────────────────────────────────────────── group('OnboardingState equality', () { test('OnboardingError with same message are equal', () { const s1 = OnboardingError('err'); const s2 = OnboardingError('err'); expect(s1, equals(s2)); }); test('OnboardingStepFormule props contains formulas list', () { final formulas = _formules(); final state = OnboardingStepFormule(formulas); expect(state.props, contains(formulas)); }); test('OnboardingStepPeriode props are correct', () { final formulas = _formules(); const state = OnboardingStepPeriode( codeFormule: 'BASIC', plage: 'PETITE', formules: [], ); expect(state.codeFormule, 'BASIC'); expect(state.plage, 'PETITE'); }); test('OnboardingPaiementEchoue props are correct', () { final sosc = _souscription(); final state = OnboardingPaiementEchoue( message: 'Error', souscription: sosc, waveLaunchUrl: 'https://wave.com', ); expect(state.props, containsAll(['Error', sosc, 'https://wave.com'])); }); }); }