From be924265a152aef2d8cef25887eeecba446fcbe6 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 9 Jul 2026 18:35:09 +0300 Subject: [PATCH] feat(sbp_payments): added cubit layer for sbp payments with tests --- lib/core/di/cubit_registrations.dart | 4 + lib/core/di/repository_registrations.dart | 2 + .../consultation_payment_cubit.dart | 98 +++++++++ .../consultation_payment_state.dart | 15 ++ .../repositories/payment_repository.dart | 18 ++ .../consultation_payment_cubit_test.dart | 188 ++++++++++++++++++ .../repositories/payment_repository_test.dart | 97 +++++++++ 7 files changed, 422 insertions(+) create mode 100644 lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart create mode 100644 lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart create mode 100644 lib/features/payment/repositories/payment_repository.dart create mode 100644 test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart create mode 100644 test/features/payment/repositories/payment_repository_test.dart diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 6f26ab95..e5a1f91b 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -39,6 +39,7 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans; @@ -135,6 +136,9 @@ class CubitRegistrations { sl.registerFactory(() => ComplainingCubit(repository: sl())); sl.registerFactory(() => UserCardsCubit(cardsRepository: sl())); sl.registerFactory(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl())); + sl.registerFactory( + () => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()), + ); sl.registerFactory(() => ConsultationsCubit(consultationsRepository: sl())); sl.registerFactory(() => ConsultantCommentsCubit(diaryRepository: sl())); sl.registerFactory(() => CancelPlanCubit(cancelPlanRepository: sl())); diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index b14fb38c..bb260432 100644 --- a/lib/core/di/repository_registrations.dart +++ b/lib/core/di/repository_registrations.dart @@ -24,6 +24,7 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart'; import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart'; +import 'package:eatvio_app/features/payment/repositories/payment_repository.dart'; import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart'; import 'package:eatvio_app/features/plans/repositories/plan_repository.dart'; @@ -76,6 +77,7 @@ class RepositoryRegistrations { sl.registerLazySingleton(() => CommentsRepository(service: sl())); sl.registerLazySingleton(() => NotificationSettingsRepository(sl())); sl.registerLazySingleton(() => SbpRepository(sbpService: sl())); + sl.registerLazySingleton(() => PaymentRepository(chatService: sl())); sl.registerLazySingleton(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl())); sl.registerLazySingleton(() => RegistrationRepositoryV2(registrationService: sl())); diff --git a/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart new file mode 100644 index 00000000..ad0ae25e --- /dev/null +++ b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart @@ -0,0 +1,98 @@ +import 'dart:developer'; + +import 'package:eatvio_app/core/error_reporting/report_from.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart'; +import 'package:eatvio_app/features/payment/repositories/payment_repository.dart'; +import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; + +/// Cubit оплаты консультации через СБП: старт оплаты, список банков, редирект в банк. +/// +/// Не хранит [planId] как поле — он передаётся в [startPayment]. +/// Polling статуса оплаты остаётся в ChatRoomCubit. +class ConsultationPaymentCubit extends Cubit { + final PaymentRepository paymentRepository; + final SbpRepository sbpRepository; + + ConsultationPaymentCubit({ + required this.paymentRepository, + required this.sbpRepository, + }) : super(ConsultationPaymentState()); + + /// Запускает оплату для [planId], сохраняет checkout_url и загружает банки СБП. + Future startPayment({required int planId}) async { + try { + emit( + state.copyWith( + isLoading: true, + errorMessage: null, + banks: const [], + checkoutUrl: null, + orderId: null, + ), + ); + + final payment = await paymentRepository.startPaymentProcess(planId: planId); + + emit( + state.copyWith( + checkoutUrl: payment.paymentUrl, + orderId: payment.orderId, + ), + ); + + try { + final banks = await sbpRepository.getInstalledBanks(); + if (isClosed) return; + emit( + state.copyWith( + banks: banks, + isLoading: false, + errorMessage: null, + ), + ); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + if (isClosed) return; + emit( + state.copyWith( + isLoading: false, + banks: const [], + errorMessage: e.toString(), + ), + ); + } + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + if (isClosed) return; + emit( + state.copyWith( + isLoading: false, + banks: const [], + checkoutUrl: null, + orderId: null, + errorMessage: e.toString(), + ), + ); + } + } + + /// Открывает банковское приложение [bank] с текущим [ConsultationPaymentState.checkoutUrl]. + Future openBank({required C2bmemberModel bank}) async { + final checkoutUrl = state.checkoutUrl; + if (checkoutUrl == null || checkoutUrl.isEmpty) { + return false; + } + + try { + return await sbpRepository.openBank(url: checkoutUrl, bank: bank); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + return false; + } + } +} diff --git a/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart new file mode 100644 index 00000000..31aa1c04 --- /dev/null +++ b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; + +part 'consultation_payment_state.freezed.dart'; + +@freezed +abstract class ConsultationPaymentState with _$ConsultationPaymentState { + factory ConsultationPaymentState({ + @Default(false) bool isLoading, + @Default([]) List banks, + String? checkoutUrl, + String? orderId, + String? errorMessage, + }) = _ConsultationPaymentState; +} diff --git a/lib/features/payment/repositories/payment_repository.dart b/lib/features/payment/repositories/payment_repository.dart new file mode 100644 index 00000000..1d590e48 --- /dev/null +++ b/lib/features/payment/repositories/payment_repository.dart @@ -0,0 +1,18 @@ +import 'package:eatvio_app/features/chat/models/payment_model.dart'; +import 'package:eatvio_app/features/chat/services/chat_service.dart'; + +/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП. +class PaymentRepository { + final ChatService chatService; + + PaymentRepository({required this.chatService}); + + /// Запускает процесс оплаты: POST `/api/chosen_plans/{id}/start_payment_process` + /// с `payment_provider: alfa-bank`. + Future startPaymentProcess({required int planId}) async { + return chatService.getPaymentLink( + id: planId, + body: const {'payment_provider': 'alfa-bank'}, + ); + } +} diff --git a/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart b/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart new file mode 100644 index 00000000..68586ec9 --- /dev/null +++ b/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart @@ -0,0 +1,188 @@ +import 'package:eatvio_app/features/chat/models/payment_model.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; +import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart'; +import 'package:eatvio_app/features/payment/repositories/payment_repository.dart'; +import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; + +import '../../../../fixtures/fixture_reader.dart'; +import 'consultation_payment_cubit_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + group('ConsultationPaymentCubit', () { + late ConsultationPaymentCubit cubit; + late MockPaymentRepository mockPaymentRepository; + late MockSbpRepository mockSbpRepository; + + final installedBank = C2bmemberModel.fromJson(const { + 'bankName': 'Сбербанк', + 'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png', + 'schema': 'bank100000000111', + 'package_name': 'ru.sberbankmobile', + 'icon': 'packages/sbp/assets/icons/sber.png', + }); + + late PaymentModel alfaPayment; + + setUp(() { + mockPaymentRepository = MockPaymentRepository(); + mockSbpRepository = MockSbpRepository(); + cubit = ConsultationPaymentCubit( + paymentRepository: mockPaymentRepository, + sbpRepository: mockSbpRepository, + ); + alfaPayment = PaymentModel.fromJson( + FixtureReader.readAsMap('chat/payments/payment_response_alfa_bank.json'), + ); + }); + + tearDown(() { + cubit.close(); + }); + + test('начальный state - isLoading=false, banks=[], checkoutUrl=null', () { + expect(cubit.state, isA()); + expect(cubit.state.isLoading, isFalse); + expect(cubit.state.banks, isEmpty); + expect(cubit.state.checkoutUrl, isNull); + expect(cubit.state.orderId, isNull); + expect(cubit.state.errorMessage, isNull); + }); + + test( + 'startPayment - успешно получает checkout_url и загружает банки', + () async { + const planId = 123; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when( + mockSbpRepository.getInstalledBanks(), + ).thenAnswer((_) async => [installedBank]); + + await cubit.startPayment(planId: planId); + + expect(cubit.state.isLoading, isFalse); + expect(cubit.state.checkoutUrl, alfaPayment.paymentUrl); + expect(cubit.state.orderId, alfaPayment.orderId); + expect(cubit.state.banks, [installedBank]); + expect(cubit.state.errorMessage, isNull); + verify( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).called(1); + verify(mockSbpRepository.getInstalledBanks()).called(1); + }, + ); + + test('startPayment - при ошибке API emit errorMessage', () async { + const planId = 123; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenThrow(Exception('Payment start failed')); + + await cubit.startPayment(planId: planId); + + expect(cubit.state.isLoading, isFalse); + expect(cubit.state.errorMessage, isNotNull); + expect(cubit.state.banks, isEmpty); + expect(cubit.state.checkoutUrl, isNull); + verifyNever(mockSbpRepository.getInstalledBanks()); + }); + + test( + 'startPayment - при ошибке SBP после успешного payment emit error', + () async { + const planId = 123; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when( + mockSbpRepository.getInstalledBanks(), + ).thenThrow(Exception('SBP plugin error')); + + await cubit.startPayment(planId: planId); + + expect(cubit.state.isLoading, isFalse); + expect(cubit.state.errorMessage, isNotNull); + expect(cubit.state.checkoutUrl, alfaPayment.paymentUrl); + expect(cubit.state.orderId, alfaPayment.orderId); + expect(cubit.state.banks, isEmpty); + }, + ); + + test('openBank - успешно открывает банк после startPayment', () async { + const planId = 123; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when( + mockSbpRepository.getInstalledBanks(), + ).thenAnswer((_) async => [installedBank]); + when( + mockSbpRepository.openBank( + url: alfaPayment.paymentUrl, + bank: installedBank, + ), + ).thenAnswer((_) async => true); + + await cubit.startPayment(planId: planId); + final result = await cubit.openBank(bank: installedBank); + + expect(result, isTrue); + verify( + mockSbpRepository.openBank( + url: alfaPayment.paymentUrl, + bank: installedBank, + ), + ).called(1); + }); + + test( + 'openBank - без checkoutUrl возвращает false и не вызывает SbpRepository', + () async { + final result = await cubit.openBank(bank: installedBank); + + expect(result, isFalse); + verifyNever( + mockSbpRepository.openBank( + url: anyNamed('url'), + bank: anyNamed('bank'), + ), + ); + }, + ); + + test('openBank - возвращает false когда банк не открылся', () async { + const planId = 123; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when( + mockSbpRepository.getInstalledBanks(), + ).thenAnswer((_) async => [installedBank]); + when( + mockSbpRepository.openBank( + url: alfaPayment.paymentUrl, + bank: installedBank, + ), + ).thenAnswer((_) async => false); + + await cubit.startPayment(planId: planId); + final result = await cubit.openBank(bank: installedBank); + + expect(result, isFalse); + }); + }); +} diff --git a/test/features/payment/repositories/payment_repository_test.dart b/test/features/payment/repositories/payment_repository_test.dart new file mode 100644 index 00000000..28d3d2f6 --- /dev/null +++ b/test/features/payment/repositories/payment_repository_test.dart @@ -0,0 +1,97 @@ +import 'package:eatvio_app/features/chat/models/payment_model.dart'; +import 'package:eatvio_app/features/chat/services/chat_service.dart'; +import 'package:eatvio_app/features/payment/repositories/payment_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../../fixtures/fixture_reader.dart'; +import 'payment_repository_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + group('PaymentRepository', () { + late PaymentRepository paymentRepository; + late MockChatService mockChatService; + + setUp(() { + mockChatService = MockChatService(); + paymentRepository = PaymentRepository(chatService: mockChatService); + }); + + test( + 'startPaymentProcess - возвращает PaymentModel из ответа alfa-bank', + () async { + const planId = 123; + final paymentFixture = FixtureReader.readAsMap( + 'chat/payments/payment_response_alfa_bank.json', + ); + final expectedPayment = PaymentModel.fromJson(paymentFixture); + final requestBody = Map.from( + FixtureReader.readAsMap('chat/payments/payment_alfabank_request.json'), + ); + + when( + mockChatService.getPaymentLink(id: planId, body: requestBody), + ).thenAnswer((_) async => expectedPayment); + + final result = await paymentRepository.startPaymentProcess(planId: planId); + + expect(result, isA()); + expect(result.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF'); + expect(result.orderId, 'alfa_c29_k15138_t1712345678_r1234'); + expect(result.paymentProvider, 'alfa-bank'); + verify( + mockChatService.getPaymentLink( + id: planId, + body: {'payment_provider': 'alfa-bank'}, + ), + ).called(1); + }, + ); + + test( + 'startPaymentProcess - передаёт payment_provider alfa-bank в тело запроса', + () async { + const planId = 42; + final paymentFixture = FixtureReader.readAsMap( + 'chat/payments/payment_response_alfa_bank.json', + ); + final expectedPayment = PaymentModel.fromJson(paymentFixture); + + when( + mockChatService.getPaymentLink( + id: planId, + body: {'payment_provider': 'alfa-bank'}, + ), + ).thenAnswer((_) async => expectedPayment); + + await paymentRepository.startPaymentProcess(planId: planId); + + verify( + mockChatService.getPaymentLink( + id: planId, + body: {'payment_provider': 'alfa-bank'}, + ), + ).called(1); + }, + ); + + test('startPaymentProcess - пробрасывает ошибку сервиса', () async { + const planId = 123; + final error = Exception('Payment start failed'); + + when( + mockChatService.getPaymentLink( + id: planId, + body: {'payment_provider': 'alfa-bank'}, + ), + ).thenThrow(error); + + expect( + () => paymentRepository.startPaymentProcess(planId: planId), + throwsA(isA()), + ); + }); + }); +}