forked from eatvio/eatvio_app
feat(payments): phase 3 test added
This commit is contained in:
2
Makefile
2
Makefile
@@ -10,4 +10,4 @@ build-generated-files:
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
clean-coverage:
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
|
||||
@@ -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>(() => BlockedUsersRepository(blockedUserService: sl()));
|
||||
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
||||
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
|
||||
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
||||
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
||||
|
||||
|
||||
@@ -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>(() => ConsultantRatingService(sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
||||
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
||||
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
|
||||
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
||||
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
||||
|
||||
|
||||
27
lib/features/payment/repositories/sbp_repository.dart
Normal file
27
lib/features/payment/repositories/sbp_repository.dart
Normal file
@@ -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<List<C2bmemberModel>> getInstalledBanks() async {
|
||||
return await sbpService.getInstalledBanks();
|
||||
}
|
||||
|
||||
/// Открывает банк для оплаты. Возвращает `false` при ошибке плагина.
|
||||
Future<bool> openBank({
|
||||
required String url,
|
||||
required C2bmemberModel bank,
|
||||
}) async {
|
||||
return await sbpService.openBank(url: url, bank: bank);
|
||||
}
|
||||
}
|
||||
43
lib/features/payment/services/sbp_service.dart
Normal file
43
lib/features/payment/services/sbp_service.dart
Normal file
@@ -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<List<C2bmemberModel>> getInstalledBanks();
|
||||
|
||||
/// Открывает банковское приложение [bank] для оплаты по ссылке [url]
|
||||
/// (checkout_url вида `https://qr.nspk.ru/...`).
|
||||
///
|
||||
/// Возвращает `true`, если приложение банка успешно запущено.
|
||||
Future<bool> openBank({required String url, required C2bmemberModel bank});
|
||||
}
|
||||
|
||||
/// Реализация [SbpService], делегирующая вызовы в статичные методы пакета `sbp`.
|
||||
class SbpServiceImpl implements SbpService {
|
||||
const SbpServiceImpl();
|
||||
|
||||
@override
|
||||
Future<List<C2bmemberModel>> getInstalledBanks() {
|
||||
// Используем встроенный справочник банков c2bmembersData (получаемый с
|
||||
// https://qr.nspk.ru/proxyapp/c2bmembers.json) и фильтруем только те, что
|
||||
// установлены на устройстве. Локальные иконки/названия включены, чтобы UI
|
||||
// не зависел от сетевой загрузки логотипов.
|
||||
return sbp.Sbp.getInstalledBanks(
|
||||
C2bmembersModel.fromJson(c2bmembersData),
|
||||
useAndroidLocalIcons: true,
|
||||
useAndroidLocalNames: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openBank({required String url, required C2bmemberModel bank}) {
|
||||
return sbp.Sbp.openBank(url, bank);
|
||||
}
|
||||
}
|
||||
118
test/features/payment/repositories/sbp_repository_test.dart
Normal file
118
test/features/payment/repositories/sbp_repository_test.dart
Normal file
@@ -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<SbpService>()])
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
119
test/features/payment/services/sbp_service_test.dart
Normal file
119
test/features/payment/services/sbp_service_test.dart
Normal file
@@ -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 <Map<String, dynamic>>[];
|
||||
});
|
||||
|
||||
final result = await sbpService.getInstalledBanks();
|
||||
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('пробрасывает ошибку плагина при отсутствии нативного обработчика', () async {
|
||||
// Обработчик не зарегистрирован → invokeMethod выбрасывает
|
||||
// MissingPluginException. Сервис должен пробросить её наверх.
|
||||
expect(
|
||||
sbpService.getInstalledBanks(),
|
||||
throwsA(isA<MissingPluginException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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<dynamic, dynamic>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user