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:
Anatoliy Poloz
2026-11-02 14:03:56 +03:00
parent 7a456c2d9d
commit 1829ad611d
15 changed files with 935 additions and 12 deletions

View File

@@ -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();
});
});
}

View File

@@ -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);
});
});
}