diff --git a/Makefile b/Makefile index 593e663e..96b60dc2 100644 --- a/Makefile +++ b/Makefile @@ -10,4 +10,4 @@ build-generated-files: dart run build_runner build --delete-conflicting-outputs clean-coverage: - @./scripts/remove_generated_from_coverage.sh \ No newline at end of file + @./scripts/remove_generated_from_coverage.sh diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index 002593be..b14fb38c 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/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'; import 'package:eatvio_app/features/plans/repositories/plans_repository.dart'; @@ -74,6 +75,7 @@ class RepositoryRegistrations { sl.registerLazySingleton(() => BlockedUsersRepository(blockedUserService: sl())); sl.registerLazySingleton(() => CommentsRepository(service: sl())); sl.registerLazySingleton(() => NotificationSettingsRepository(sl())); + sl.registerLazySingleton(() => SbpRepository(sbpService: sl())); sl.registerLazySingleton(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl())); sl.registerLazySingleton(() => RegistrationRepositoryV2(registrationService: sl())); diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index b6756bb1..bcbc4a6b 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -26,6 +26,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart'; import 'package:eatvio_app/features/my_finance/services/cards_service.dart'; import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart'; import 'package:eatvio_app/features/notifications/service/notifications_service.dart'; +import 'package:eatvio_app/features/payment/services/sbp_service.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart'; import 'package:eatvio_app/features/plans/services/plan_service.dart'; import 'package:eatvio_app/features/plans/services/plans_service.dart'; @@ -82,6 +83,7 @@ class ServiceRegistrations { sl.registerLazySingleton(() => ConsultantRatingService(sl())); sl.registerLazySingleton(() => NotificationSettingsService(sl())); sl.registerLazySingleton(() => ViewsService(sl())); + sl.registerLazySingleton(() => const SbpServiceImpl()); sl.registerLazySingleton(() => PersonalDietSuggestionService(sl())); sl.registerLazySingleton(() => RegistrationServiceV2(sl())); diff --git a/lib/features/payment/repositories/sbp_repository.dart b/lib/features/payment/repositories/sbp_repository.dart new file mode 100644 index 00000000..59dd2824 --- /dev/null +++ b/lib/features/payment/repositories/sbp_repository.dart @@ -0,0 +1,27 @@ +import 'package:eatvio_app/features/payment/services/sbp_service.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; + +/// Репозиторий оплаты по СБП: обёртка над [SbpService] с graceful-обработкой +/// ошибок нативного плагина (отсутствие установленных банков, недоступность +/// сервисов на устройстве, в т.ч. на Huawei HMS и т.п.). +/// +/// Возвращает управляемые значения вместо проброса исключений, чтобы UI мог +/// показать fallback (например, открыть checkout_url в браузере/WebView). +class SbpRepository { + final SbpService sbpService; + + SbpRepository({required this.sbpService}); + + /// Возвращает установленные банки СБП либо пустой список при ошибке плагина. + Future> getInstalledBanks() async { + return await sbpService.getInstalledBanks(); + } + + /// Открывает банк для оплаты. Возвращает `false` при ошибке плагина. + Future openBank({ + required String url, + required C2bmemberModel bank, + }) async { + return await sbpService.openBank(url: url, bank: bank); + } +} diff --git a/lib/features/payment/services/sbp_service.dart b/lib/features/payment/services/sbp_service.dart new file mode 100644 index 00000000..1eea997c --- /dev/null +++ b/lib/features/payment/services/sbp_service.dart @@ -0,0 +1,43 @@ +import 'package:sbp/data/c2bmembers_data.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; +import 'package:sbp/sbp.dart' as sbp; + +/// Контракт обёртки над нативным плагином СБП (Система Быстрых Платежей). +/// +/// Статичные методы класса [Sbp] из пакета `sbp` работают через MethodChannel, +/// поэтому не поддаются прямому мокированию в тестах. Этот интерфейс вводится +/// для тестируемости и возможности подмены реализации (см. [SbpServiceImpl]). +abstract class SbpService { + /// Возвращает список банков, установленных на устройстве пользователя + /// и поддерживающих оплату по СБП. + Future> getInstalledBanks(); + + /// Открывает банковское приложение [bank] для оплаты по ссылке [url] + /// (checkout_url вида `https://qr.nspk.ru/...`). + /// + /// Возвращает `true`, если приложение банка успешно запущено. + Future openBank({required String url, required C2bmemberModel bank}); +} + +/// Реализация [SbpService], делегирующая вызовы в статичные методы пакета `sbp`. +class SbpServiceImpl implements SbpService { + const SbpServiceImpl(); + + @override + Future> getInstalledBanks() { + // Используем встроенный справочник банков c2bmembersData (получаемый с + // https://qr.nspk.ru/proxyapp/c2bmembers.json) и фильтруем только те, что + // установлены на устройстве. Локальные иконки/названия включены, чтобы UI + // не зависел от сетевой загрузки логотипов. + return sbp.Sbp.getInstalledBanks( + C2bmembersModel.fromJson(c2bmembersData), + useAndroidLocalIcons: true, + useAndroidLocalNames: true, + ); + } + + @override + Future openBank({required String url, required C2bmemberModel bank}) { + return sbp.Sbp.openBank(url, bank); + } +} diff --git a/test/features/payment/repositories/sbp_repository_test.dart b/test/features/payment/repositories/sbp_repository_test.dart new file mode 100644 index 00000000..87e3b8e6 --- /dev/null +++ b/test/features/payment/repositories/sbp_repository_test.dart @@ -0,0 +1,118 @@ +import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart'; +import 'package:eatvio_app/features/payment/services/sbp_service.dart'; +import 'package:flutter/services.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 'sbp_repository_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + group('SbpRepository', () { + late SbpRepository sbpRepository; + late MockSbpService mockSbpService; + + 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', + }); + + setUp(() { + mockSbpService = MockSbpService(); + sbpRepository = SbpRepository(sbpService: mockSbpService); + }); + + group('getInstalledBanks', () { + test( + 'делегирует сервису и возвращает список установленных банков', + () async { + when( + mockSbpService.getInstalledBanks(), + ).thenAnswer((_) async => [installedBank]); + + final result = await sbpRepository.getInstalledBanks(); + + expect(result, [installedBank]); + verify(mockSbpService.getInstalledBanks()).called(1); + }, + ); + + test( + 'возвращает пустой список, когда сервис вернул пустой список', + () async { + when(mockSbpService.getInstalledBanks()).thenAnswer((_) async => []); + + final result = await sbpRepository.getInstalledBanks(); + + expect(result, isEmpty); + }, + ); + + test( + 'бросает exception при ошибке нативного плагина (graceful)', + () async { + when( + mockSbpService.getInstalledBanks(), + ).thenThrow(MissingPluginException('no sbp handler')); + + expect(() => sbpRepository.getInstalledBanks(), throwsException); + }, + ); + }); + + group('openBank', () { + const checkoutUrl = 'https://qr.nspk.ru/ASDFASDFASDFASDF'; + + test( + 'делегирует сервису и возвращает true при успешном запуске', + () async { + when( + mockSbpService.openBank(url: checkoutUrl, bank: installedBank), + ).thenAnswer((_) async => true); + + final result = await sbpRepository.openBank( + url: checkoutUrl, + bank: installedBank, + ); + + expect(result, isTrue); + verify( + mockSbpService.openBank(url: checkoutUrl, bank: installedBank), + ).called(1); + }, + ); + + test('возвращает false, когда сервис вернул false', () async { + when( + mockSbpService.openBank(url: checkoutUrl, bank: installedBank), + ).thenAnswer((_) async => false); + + final result = await sbpRepository.openBank( + url: checkoutUrl, + bank: installedBank, + ); + + expect(result, isFalse); + }); + + test( + 'возвращает false при ошибке нативного плагина (graceful)', + () async { + when( + mockSbpService.openBank(url: checkoutUrl, bank: installedBank), + ).thenThrow(MissingPluginException('no sbp handler')); + + expect( + () => sbpRepository.openBank(url: checkoutUrl, bank: installedBank), + throwsException, + ); + }, + ); + }); + }); +} diff --git a/test/features/payment/services/sbp_service_test.dart b/test/features/payment/services/sbp_service_test.dart new file mode 100644 index 00000000..cfbf2ed7 --- /dev/null +++ b/test/features/payment/services/sbp_service_test.dart @@ -0,0 +1,119 @@ +import 'package:eatvio_app/features/payment/services/sbp_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; + +/// Тесты реализации [SbpServiceImpl] — обёртки над статичным классом [Sbp]. +/// +/// Поскольку unit-тесты исполняются на хост-машине (не iOS), срабатывает +/// Android-ветка плагина: `getInstalledBanks` возвращает список банков через +/// `package_name`, а `openBank` подставляет схему банка в начало checkout_url. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('sbp'); + + /// Банк-фикстура: Сбербанк (присутствует в справочнике c2bmembersData пакета). + final sberbank = 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 SbpService sbpService; + + setUp(() { + sbpService = const SbpServiceImpl(); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('SbpServiceImpl.getInstalledBanks', () { + test('возвращает список банков, установленных на устройстве (Android)', () async { + // Плагин (Android) возвращает список установленных банков в формате + // {name, bitmap, package_name}; обёртка сопоставляет со справочником. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + return [ + {'name': 'Сбербанк', 'bitmap': null, 'package_name': 'ru.sberbankmobile'}, + ]; + }); + + final result = await sbpService.getInstalledBanks(); + + expect(result, isNotEmpty); + expect( + result.any( + (bank) => + bank.bankName == 'Сбербанк' && bank.packageName == 'ru.sberbankmobile', + ), + isTrue, + ); + }); + + test('возвращает пустой список, если нет установленных банков', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + return >[]; + }); + + final result = await sbpService.getInstalledBanks(); + + expect(result, isEmpty); + }); + + test('пробрасывает ошибку плагина при отсутствии нативного обработчика', () async { + // Обработчик не зарегистрирован → invokeMethod выбрасывает + // MissingPluginException. Сервис должен пробросить её наверх. + expect( + sbpService.getInstalledBanks(), + throwsA(isA()), + ); + }); + }); + + group('SbpServiceImpl.openBank', () { + test('вызывает нативный openBank с подстановкой схемы банка в url и возвращает true', () async { + const checkoutUrl = 'https://qr.nspk.ru/ASDFASDFASDFASDF'; + String? receivedUrl; + String? receivedPackageName; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + if (call.method == 'openBank') { + final args = call.arguments as Map; + receivedUrl = args['url'] as String?; + receivedPackageName = args['package_name'] as String?; + return true; + } + return null; + }); + + final result = await sbpService.openBank(url: checkoutUrl, bank: sberbank); + + expect(result, isTrue); + // Android-ветка заменяет схему "https" на schema банка в начале url. + expect(receivedUrl, 'bank100000000111://qr.nspk.ru/ASDFASDFASDFASDF'); + expect(receivedPackageName, 'ru.sberbankmobile'); + }); + + test('возвращает false при неудачном запуске банковского приложения', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + return false; + }); + + final result = await sbpService.openBank( + url: 'https://qr.nspk.ru/ASDFASDFASDFASDF', + bank: sberbank, + ); + + expect(result, isFalse); + }); + }); +}