forked from eatvio/eatvio_app
feat(sbp_payments): QR-код оплаты СБП с другого устройства
- Новый SbpQrBottomSheet: рендер checkout_url (qr.nspk.ru/{qrcId}) через qr_flutter,
сумма, инструкция, автоопределение оплаты polling-ом статуса chosen_plan (3 сек)
и автозакрытие с успехом
- Кнопка «Оплатить по QR-коду» в шите выбора банков — первичная, под заголовком,
доступна сразу после получения checkout_url, не дожидаясь списка банков
(важно для устройств без банковских приложений, iOS в РФ)
- Блок «Симуляция оплаты» (testpay.alfabank.ru/sbp-page?qrcId=...) показывается
только когда base URL API не продуктовый: AppConfig.isProdApi/prodApiHosts
(хост целиком — стенды-поддомены eatvio.ru продом не считаются)
- showBottomSheetWithBlur стал генериком — возвращает значение pop(result)
- showConsultationPaymentFlow: опциональная прокидка суммы в QR-шит
- Локализация ru/en, тесты (16 новых, всего 34 платёжных зелёные)
This commit is contained in:
@@ -1,18 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.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/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:sbp/models/c2bmembers_model.dart';
|
||||
|
||||
import '../../../../fixtures/fixture_reader.dart';
|
||||
@@ -21,9 +29,21 @@ import 'sbp_banks_bottom_sheet_test.mocks.dart';
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<PaymentRepository>(),
|
||||
MockSpec<SbpRepository>(),
|
||||
MockSpec<ChatRepository>(),
|
||||
])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// AppConfig в конструкторе читает EnvConfig (dotenv) — инициализируем.
|
||||
setUpAll(() async {
|
||||
await dotenv.load(
|
||||
fileName: 'test.env',
|
||||
isOptional: true,
|
||||
mergeWith: const {
|
||||
'BASE_URL': 'https://eatvio.ru',
|
||||
'DEV_BASE_URL': 'https://dev2.eatvio.com',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('SbpBanksBottomSheet', () {
|
||||
late MockPaymentRepository mockPaymentRepository;
|
||||
@@ -183,5 +203,66 @@ void main() {
|
||||
expect(find.byType(Loader), findsNothing);
|
||||
expect(find.text('Нет установленных банков'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('QR-кнопка доступна до загрузки списка банков', (tester) async {
|
||||
const planId = 12;
|
||||
final banksCompleter = Completer<List<C2bmemberModel>>();
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) => banksCompleter.future);
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() => AppConfig(buildType: AppBuildType.tests),
|
||||
);
|
||||
sl.registerLazySingleton<ChatRepository>(() => MockChatRepository());
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Банки ещё грузятся (Loader), а QR-кнопка уже доступна.
|
||||
expect(find.byType(Loader), findsOneWidget);
|
||||
expect(find.text('Оплатить по QR-коду'), findsOneWidget);
|
||||
|
||||
banksCompleter.complete([installedBank]);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Loader), findsNothing);
|
||||
expect(find.text('Сбербанк'), findsOneWidget);
|
||||
expect(find.text('Оплатить по QR-коду'), findsOneWidget);
|
||||
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
|
||||
testWidgets('после старта оплаты показывает кнопку QR и открывает QR-шит', (tester) async {
|
||||
const planId = 12;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() => AppConfig(buildType: AppBuildType.tests),
|
||||
);
|
||||
sl.registerLazySingleton<ChatRepository>(() => MockChatRepository());
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Оплатить по QR-коду'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Оплатить по QR-коду'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(SbpQrBottomSheet), findsOneWidget);
|
||||
expect(find.byType(QrImageView), findsOneWidget);
|
||||
|
||||
// Закрываем QR-шит (останавливает polling-таймер).
|
||||
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
await GetIt.I.reset();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.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/presentation/widgets/sbp_qr_bottom_sheet.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/plans/enums/chosen_plan_state_enum.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import 'sbp_qr_bottom_sheet_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<PaymentRepository>(),
|
||||
MockSpec<SbpRepository>(),
|
||||
MockSpec<ChatRepository>(),
|
||||
MockSpec<ChosenPlanModel>(),
|
||||
])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const planId = 12;
|
||||
const checkoutUrl = 'https://qr.nspk.ru/AD10106TB0BVFTBG9DQ96NKJEIVVTKIO';
|
||||
|
||||
group('SbpQrBottomSheet', () {
|
||||
late MockPaymentRepository mockPaymentRepository;
|
||||
late MockSbpRepository mockSbpRepository;
|
||||
late MockChatRepository mockChatRepository;
|
||||
late MockChosenPlanModel mockPlan;
|
||||
late ConsultationPaymentCubit cubit;
|
||||
late Future<bool?>? sheetResult;
|
||||
|
||||
setUp(() async {
|
||||
await GetIt.I.reset();
|
||||
// AppConfig в конструкторе читает EnvConfig (dotenv) — инициализируем.
|
||||
await dotenv.load(
|
||||
fileName: 'test.env',
|
||||
isOptional: true,
|
||||
mergeWith: const {
|
||||
'BASE_URL': 'https://eatvio.ru',
|
||||
'DEV_BASE_URL': 'https://dev2.eatvio.com',
|
||||
},
|
||||
);
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() => AppConfig(buildType: AppBuildType.tests),
|
||||
);
|
||||
sl.registerLazySingleton<ChatRepository>(() => mockChatRepository);
|
||||
|
||||
mockPaymentRepository = MockPaymentRepository();
|
||||
mockSbpRepository = MockSbpRepository();
|
||||
mockChatRepository = MockChatRepository();
|
||||
mockPlan = MockChosenPlanModel();
|
||||
cubit = ConsultationPaymentCubit(
|
||||
paymentRepository: mockPaymentRepository,
|
||||
sbpRepository: mockSbpRepository,
|
||||
);
|
||||
cubit.emit(
|
||||
ConsultationPaymentState(
|
||||
isLoading: false,
|
||||
checkoutUrl: checkoutUrl,
|
||||
),
|
||||
);
|
||||
sheetResult = null;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await GetIt.I.reset();
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
Future<void> pumpApp(WidgetTester tester, {double? amount}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => sheetResult = showSbpQrBottomSheet(
|
||||
context,
|
||||
planId: planId,
|
||||
cubit: cubit,
|
||||
amount: amount,
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> openSheet(WidgetTester tester, {double? amount}) async {
|
||||
await pumpApp(tester, amount: amount);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// Закрывает шит, чтобы остановить polling-таймер в конце теста.
|
||||
Future<void> closeSheet(WidgetTester tester) async {
|
||||
final navigator = tester.state<NavigatorState>(
|
||||
find.byType(Navigator).first,
|
||||
);
|
||||
navigator.pop();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void stubPlanState(ChosenPlanStateEnum state) {
|
||||
when(mockChatRepository.getChosenPlan(id: planId))
|
||||
.thenAnswer((_) async => mockPlan);
|
||||
when(mockPlan.chosenPlanState).thenReturn(state);
|
||||
}
|
||||
|
||||
/// Перерегистрирует DI с AppConfig, смотрящим на конкретный base URL.
|
||||
Future<void> useApiBaseUrl(String baseUrl) async {
|
||||
await GetIt.I.reset();
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl(baseUrl),
|
||||
);
|
||||
sl.registerLazySingleton<ChatRepository>(() => mockChatRepository);
|
||||
}
|
||||
|
||||
testWidgets('рендерит QR-код из checkout_url и инструкцию', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
expect(find.byKey(const ValueKey('sbp_qr_code')), findsOneWidget);
|
||||
expect(find.byType(QrImageView), findsOneWidget);
|
||||
expect(find.text('Оплата по QR-коду'), findsOneWidget);
|
||||
expect(find.text('Ожидание оплаты…'), findsOneWidget);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
|
||||
testWidgets('показывает сумму, если передана', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
|
||||
await openSheet(tester, amount: 1200);
|
||||
|
||||
expect(find.text('Сумма к оплате'), findsOneWidget);
|
||||
expect(find.text('1200 ₽'), findsOneWidget);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
|
||||
testWidgets('не показывает сумму, если не передана', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
expect(find.text('Сумма к оплате'), findsNothing);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
|
||||
testWidgets('показывает тестовую кнопку на stage-стенде (dev2)', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
await useApiBaseUrl('https://dev2.eatvio.com');
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
expect(find.text('Открыть тестовую оплату'), findsOneWidget);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
|
||||
testWidgets('показывает тестовую кнопку на стенде-поддомене прод-хоста', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
// Поддомен eatvio.ru — стенд (например, hasan), а не прод.
|
||||
await useApiBaseUrl('https://ubooquiequ7ohz9ias3aik4a-hasan.eatvio.ru');
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
expect(find.text('Открыть тестовую оплату'), findsOneWidget);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
|
||||
testWidgets('скрывает тестовую кнопку на продуктовом API', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
await useApiBaseUrl('https://eatvio.ru');
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
expect(find.text('Открыть тестовую оплату'), findsNothing);
|
||||
// Сам QR при этом работает.
|
||||
expect(find.byType(QrImageView), findsOneWidget);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
|
||||
testWidgets('скрывает тестовую кнопку на продуктовом API (eatvio.com)', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
await useApiBaseUrl('https://eatvio.com');
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
expect(find.text('Открыть тестовую оплату'), findsNothing);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
|
||||
testWidgets('при оплате (statePaid) показывает успех и закрывает шит', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePaid);
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
// Первый poll выполняется сразу в initState.
|
||||
await tester.pump();
|
||||
expect(find.text('Оплата прошла успешно!'), findsOneWidget);
|
||||
|
||||
// Автозакрытие через 2 секунды.
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pump();
|
||||
expect(find.byType(SbpQrBottomSheet), findsNothing);
|
||||
expect(await sheetResult, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('до оплаты продолжает ждать', (tester) async {
|
||||
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
|
||||
|
||||
await openSheet(tester);
|
||||
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Оплата прошла успешно!'), findsNothing);
|
||||
expect(find.text('Ожидание оплаты…'), findsOneWidget);
|
||||
|
||||
await closeSheet(tester);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user