forked from eatvio/eatvio_app
feat(sbp_payments): added cubit layer for sbp payments with tests
This commit is contained in:
@@ -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>(() => ComplainingCubit(repository: sl()));
|
||||
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<ConsultationPaymentCubit>(
|
||||
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
||||
|
||||
@@ -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>(() => CommentsRepository(service: sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
||||
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
|
||||
sl.registerLazySingleton<PaymentRepository>(() => PaymentRepository(chatService: sl()));
|
||||
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
||||
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
||||
|
||||
|
||||
@@ -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<ConsultationPaymentState> {
|
||||
final PaymentRepository paymentRepository;
|
||||
final SbpRepository sbpRepository;
|
||||
|
||||
ConsultationPaymentCubit({
|
||||
required this.paymentRepository,
|
||||
required this.sbpRepository,
|
||||
}) : super(ConsultationPaymentState());
|
||||
|
||||
/// Запускает оплату для [planId], сохраняет checkout_url и загружает банки СБП.
|
||||
Future<void> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<C2bmemberModel> banks,
|
||||
String? checkoutUrl,
|
||||
String? orderId,
|
||||
String? errorMessage,
|
||||
}) = _ConsultationPaymentState;
|
||||
}
|
||||
18
lib/features/payment/repositories/payment_repository.dart
Normal file
18
lib/features/payment/repositories/payment_repository.dart
Normal file
@@ -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<PaymentModel> startPaymentProcess({required int planId}) async {
|
||||
return chatService.getPaymentLink(
|
||||
id: planId,
|
||||
body: const {'payment_provider': 'alfa-bank'},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PaymentRepository>(),
|
||||
MockSpec<SbpRepository>(),
|
||||
])
|
||||
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<ConsultationPaymentState>());
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<ChatService>()])
|
||||
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<String, String>.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<PaymentModel>());
|
||||
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<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user