From 3852a06ab40850b9b4a7c22eb94041751a3bc816 Mon Sep 17 00:00:00 2001 From: Anatoliy Date: Tue, 7 Jul 2026 12:22:05 +0300 Subject: [PATCH 01/16] feat(payment): new sbp payment fixture added --- test/fixtures/chat/payment_response_alfa_bank.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 test/fixtures/chat/payment_response_alfa_bank.json diff --git a/test/fixtures/chat/payment_response_alfa_bank.json b/test/fixtures/chat/payment_response_alfa_bank.json new file mode 100644 index 00000000..d8a1fbe9 --- /dev/null +++ b/test/fixtures/chat/payment_response_alfa_bank.json @@ -0,0 +1,5 @@ + { + "checkout_url" : "https://qr.nspk.ru/ASDFASDFASDFASDF", + "payment_provider" : "alfa-bank", + "order_id" : "alfa_c29_k15138_t1712345678_r1234" + } \ No newline at end of file From 7183fc5d7dc68c29aa76501bc3751bedf4ae455b Mon Sep 17 00:00:00 2001 From: Anatoliy Date: Wed, 8 Jul 2026 10:50:12 +0300 Subject: [PATCH 02/16] feat(sbp-payin): fixtures and banks assets added --- android/app/src/main/AndroidManifest.xml | 163 ++++++++++++++++++ ios/Runner/Info.plist | 51 ++++++ pubspec.yaml | 1 + .../chat/alfa_status_failed_response.json | 6 + .../chat/alfa_status_pending_response.json | 6 + .../chat/alfa_status_processing_response.json | 6 + .../chat/alfa_status_success_response.json | 6 + .../chat/payment_response_alfa_bank.json | 11 +- 8 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/chat/alfa_status_failed_response.json create mode 100644 test/fixtures/chat/alfa_status_pending_response.json create mode 100644 test/fixtures/chat/alfa_status_processing_response.json create mode 100644 test/fixtures/chat/alfa_status_success_response.json diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 116b494b..167f7b48 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -88,5 +88,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 30bbd104..c5522c3b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -74,6 +74,57 @@ yandexauth yandexauth2 yandexauth4 + + bank100000000111 + bank100000000004 + bank100000000005 + bank100000000008 + bank100000000007 + bank100000000015 + bank100000000001 + bank100000000010 + bank100000000013 + bank100000000012 + bank100000000020 + bank100000000025 + bank100000000030 + bank100000000030_new + bank100000000003 + bank100000000043 + bank100000000028 + bank100000000086 + bank100000000011 + bank100000000044 + bank100000000049 + bank100000000500 + bank100000000095 + bank100000000900 + bank100000000056 + bank100000000053 + bank100000000121 + bank100000000082 + bank100000000127 + bank100000000017 + bank100000000087 + bank100000000052 + bank100000000006 + bank100000000098 + bank100000000092 + bank100000000229 + bank100000000027 + bank100000000080 + bank100000000122 + bank100000000124 + bank100000000118 + bank100000000159 + bank100000000146 + bank100000000069 + bank100000000140 + bank100000000047 + bank100000000099 + bank100000000135 + bank100000000139 + bank100000000022 YAClientId 5399b03636104c6d920b87c1ad329254 diff --git a/pubspec.yaml b/pubspec.yaml index 1c3c8489..509986fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,6 +85,7 @@ dependencies: intl_utils: ^2.8.13 sentry_dio: ^9.10.0 livekit_client: ^2.6.1 + sbp: ^0.1.1 dev_dependencies: build_runner: ^2.10.4 diff --git a/test/fixtures/chat/alfa_status_failed_response.json b/test/fixtures/chat/alfa_status_failed_response.json new file mode 100644 index 00000000..934b0487 --- /dev/null +++ b/test/fixtures/chat/alfa_status_failed_response.json @@ -0,0 +1,6 @@ +{ + "order_id": "alfa_c29_k15138_t1712345678_r1234", + "status": "failed", + "is_paid": false, + "is_expired": false +} diff --git a/test/fixtures/chat/alfa_status_pending_response.json b/test/fixtures/chat/alfa_status_pending_response.json new file mode 100644 index 00000000..d928665f --- /dev/null +++ b/test/fixtures/chat/alfa_status_pending_response.json @@ -0,0 +1,6 @@ +{ + "order_id": "alfa_c29_k15138_t1712345678_r1234", + "status": "pending", + "is_paid": false, + "is_expired": false +} diff --git a/test/fixtures/chat/alfa_status_processing_response.json b/test/fixtures/chat/alfa_status_processing_response.json new file mode 100644 index 00000000..08cb35c3 --- /dev/null +++ b/test/fixtures/chat/alfa_status_processing_response.json @@ -0,0 +1,6 @@ +{ + "order_id": "alfa_c29_k15138_t1712345678_r1234", + "status": "processing", + "is_paid": false, + "is_expired": false +} diff --git a/test/fixtures/chat/alfa_status_success_response.json b/test/fixtures/chat/alfa_status_success_response.json new file mode 100644 index 00000000..53be4cae --- /dev/null +++ b/test/fixtures/chat/alfa_status_success_response.json @@ -0,0 +1,6 @@ +{ + "order_id": "alfa_c29_k15138_t1712345678_r1234", + "status": "success", + "is_paid": true, + "is_expired": false +} diff --git a/test/fixtures/chat/payment_response_alfa_bank.json b/test/fixtures/chat/payment_response_alfa_bank.json index d8a1fbe9..85ba3bb2 100644 --- a/test/fixtures/chat/payment_response_alfa_bank.json +++ b/test/fixtures/chat/payment_response_alfa_bank.json @@ -1,5 +1,6 @@ - { - "checkout_url" : "https://qr.nspk.ru/ASDFASDFASDFASDF", - "payment_provider" : "alfa-bank", - "order_id" : "alfa_c29_k15138_t1712345678_r1234" - } \ No newline at end of file +{ + "checkout_url": "https://qr.nspk.ru/ASDFASDFASDFASDF", + "payment_provider": "alfa-bank", + "order_id": "alfa_c29_k15138_t1712345678_r1234", + "status_token": "abc123def456" +} \ No newline at end of file From 6d920857f604e9f7372a06bcb22e68e7dcf7895f Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Wed, 8 Jul 2026 11:02:58 +0300 Subject: [PATCH 03/16] fix(gradle) --- android/app/src/main/AndroidManifest.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 167f7b48..b724e0c5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -96,7 +96,7 @@ - + @@ -105,7 +105,7 @@ - + @@ -198,7 +198,7 @@ - + From 41e556d172e9279936ae5828f4ecbb374a692686 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 8 Jul 2026 12:20:11 +0300 Subject: [PATCH 04/16] feat(sbp_payments): fixtures and dio excp type added --- lib/core/error_reporting/error_report_policy.dart | 1 + test/fixtures/chat/order_id_to_chat_uuid_response.json | 5 +++++ test/fixtures/chat/payment_alfabank_request.json | 3 +++ test/fixtures/chat/payment_mock_requeste.json | 3 +++ test/fixtures/chat/payment_response_alfa_bank.json | 3 +-- 5 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/chat/order_id_to_chat_uuid_response.json create mode 100644 test/fixtures/chat/payment_alfabank_request.json create mode 100644 test/fixtures/chat/payment_mock_requeste.json diff --git a/lib/core/error_reporting/error_report_policy.dart b/lib/core/error_reporting/error_report_policy.dart index 5e813fd8..fccc80b7 100644 --- a/lib/core/error_reporting/error_report_policy.dart +++ b/lib/core/error_reporting/error_report_policy.dart @@ -45,6 +45,7 @@ class ErrorReportPolicy { case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: case DioExceptionType.connectionError: + case DioExceptionType.transformTimeout: return true; case DioExceptionType.unknown: case DioExceptionType.badResponse: diff --git a/test/fixtures/chat/order_id_to_chat_uuid_response.json b/test/fixtures/chat/order_id_to_chat_uuid_response.json new file mode 100644 index 00000000..0e74edc1 --- /dev/null +++ b/test/fixtures/chat/order_id_to_chat_uuid_response.json @@ -0,0 +1,5 @@ + +{ + "chosen_plan_id": 12, + "conversation_uuid": "fbd81429-8019-499a-80e8-e3933cc4d872" +} \ No newline at end of file diff --git a/test/fixtures/chat/payment_alfabank_request.json b/test/fixtures/chat/payment_alfabank_request.json new file mode 100644 index 00000000..f2dc7687 --- /dev/null +++ b/test/fixtures/chat/payment_alfabank_request.json @@ -0,0 +1,3 @@ +{ + "payment_provider": "alfa-bank" +} \ No newline at end of file diff --git a/test/fixtures/chat/payment_mock_requeste.json b/test/fixtures/chat/payment_mock_requeste.json new file mode 100644 index 00000000..dec746a3 --- /dev/null +++ b/test/fixtures/chat/payment_mock_requeste.json @@ -0,0 +1,3 @@ +{ + "payment_provider": "mock" +} \ No newline at end of file diff --git a/test/fixtures/chat/payment_response_alfa_bank.json b/test/fixtures/chat/payment_response_alfa_bank.json index 85ba3bb2..c22c0345 100644 --- a/test/fixtures/chat/payment_response_alfa_bank.json +++ b/test/fixtures/chat/payment_response_alfa_bank.json @@ -1,6 +1,5 @@ { "checkout_url": "https://qr.nspk.ru/ASDFASDFASDFASDF", "payment_provider": "alfa-bank", - "order_id": "alfa_c29_k15138_t1712345678_r1234", - "status_token": "abc123def456" + "order_id": "alfa_c29_k15138_t1712345678_r1234" } \ No newline at end of file From 2ab26bbda5da05da46abe4580e5dea9532efd133 Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Wed, 8 Jul 2026 12:31:06 +0300 Subject: [PATCH 05/16] feat(chat): reorganize test fixtures --- .../repositories/chat_repository_test.dart | 276 +++++++++++------- .../chat/services/chat_service_test.dart | 197 ++++++++++--- .../alfa_status_failed_response.json | 0 .../alfa_status_pending_response.json | 0 .../alfa_status_processing_response.json | 0 .../alfa_status_success_response.json | 0 .../order_id_to_chat_uuid_response.json | 0 .../payment_alfabank_request.json | 0 .../{ => payments}/payment_mock_requeste.json | 0 .../chat/{ => payments}/payment_response.json | 0 .../payment_response_alfa_bank.json | 0 11 files changed, 317 insertions(+), 156 deletions(-) rename test/fixtures/chat/{ => payments}/alfa_status_failed_response.json (100%) rename test/fixtures/chat/{ => payments}/alfa_status_pending_response.json (100%) rename test/fixtures/chat/{ => payments}/alfa_status_processing_response.json (100%) rename test/fixtures/chat/{ => payments}/alfa_status_success_response.json (100%) rename test/fixtures/chat/{ => payments}/order_id_to_chat_uuid_response.json (100%) rename test/fixtures/chat/{ => payments}/payment_alfabank_request.json (100%) rename test/fixtures/chat/{ => payments}/payment_mock_requeste.json (100%) rename test/fixtures/chat/{ => payments}/payment_response.json (100%) rename test/fixtures/chat/{ => payments}/payment_response_alfa_bank.json (100%) diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart index 53f8a9bd..f60e028e 100644 --- a/test/features/chat/repositories/chat_repository_test.dart +++ b/test/features/chat/repositories/chat_repository_test.dart @@ -5,7 +5,8 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; -import 'package:eatvio_app/features/chat/models/chat_room_model.dart' as chat_room; +import 'package:eatvio_app/features/chat/models/chat_room_model.dart' + as chat_room; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; @@ -32,7 +33,9 @@ void main() { }); test('emitNewMessageToChats - эмитит новое сообщение в поток', () async { - final messageFixture = FixtureReader.readAsMap('chat/chat_message_response.json'); + final messageFixture = FixtureReader.readAsMap( + 'chat/chat_message_response.json', + ); final message = ChatMessageModel.fromJson(messageFixture); chatRepository.emitNewMessageToChats(message); @@ -45,12 +48,14 @@ void main() { const data = 'Test message'; const socketId = 'socket-id-123'; - when(mockChatService.sendMessage( - slug: slug, - data: data, - mediaData: null, - socketId: socketId, - )).thenAnswer((_) async => Future.value()); + when( + mockChatService.sendMessage( + slug: slug, + data: data, + mediaData: null, + socketId: socketId, + ), + ).thenAnswer((_) async => Future.value()); // Note: SocketIdStorage is static and can't be easily mocked // This test verifies the repository calls the service correctly @@ -68,12 +73,14 @@ void main() { const data = 'Test message with files'; const socketId = 'socket-id-123'; - when(mockChatService.sendMessage( - slug: slug, - data: data, - mediaData: anyNamed('mediaData'), - socketId: socketId, - )).thenAnswer((_) async => Future.value()); + when( + mockChatService.sendMessage( + slug: slug, + data: data, + mediaData: anyNamed('mediaData'), + socketId: socketId, + ), + ).thenAnswer((_) async => Future.value()); // Создаем временные файлы для теста final tempFile1 = File('test1.jpg'); @@ -113,11 +120,14 @@ void main() { test('getChatRoom - получает комнату чата по UUID', () async { const uuid = 'chat-1'; - final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); + final roomFixture = FixtureReader.readAsMap( + 'chat/chat_room_response.json', + ); final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); - when(mockChatService.getChatRoom(uuid: uuid)) - .thenAnswer((_) async => expectedRoom); + when( + mockChatService.getChatRoom(uuid: uuid), + ).thenAnswer((_) async => expectedRoom); final result = await chatRepository.getChatRoom(uuid); @@ -132,18 +142,20 @@ void main() { when(mockChatService.getChatRoom(uuid: uuid)).thenThrow(error); - expect( - () => chatRepository.getChatRoom(uuid), - throwsException, - ); + expect(() => chatRepository.getChatRoom(uuid), throwsException); }); test('getChatRooms - получает список комнат чата', () async { - final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); - final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); + final paginationFixture = FixtureReader.readAsMap( + 'chat/chat_room_pagination_response.json', + ); + final expectedPagination = ChatRoomPaginationModel.fromJson( + paginationFixture, + ); - when(mockChatService.getChatRooms(query: null, page: 1)) - .thenAnswer((_) async => expectedPagination); + when( + mockChatService.getChatRooms(query: null, page: 1), + ).thenAnswer((_) async => expectedPagination); final result = await chatRepository.getChatRooms(); @@ -154,11 +166,16 @@ void main() { test('getChatRooms - получает список комнат с запросом', () async { const query = 'test query'; - final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); - final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); + final paginationFixture = FixtureReader.readAsMap( + 'chat/chat_room_pagination_response.json', + ); + final expectedPagination = ChatRoomPaginationModel.fromJson( + paginationFixture, + ); - when(mockChatService.getChatRooms(query: query, page: 1)) - .thenAnswer((_) async => expectedPagination); + when( + mockChatService.getChatRooms(query: query, page: 1), + ).thenAnswer((_) async => expectedPagination); final result = await chatRepository.getChatRooms(query: query); @@ -181,8 +198,9 @@ void main() { ), ); - when(mockChatService.getChatRooms(query: null, page: 2)) - .thenAnswer((_) async => expectedPagination); + when( + mockChatService.getChatRooms(query: null, page: 2), + ).thenAnswer((_) async => expectedPagination); final result = await chatRepository.getChatRooms(page: 2); @@ -193,11 +211,14 @@ void main() { test('getChatMessages - получает сообщения чата', () async { const chatId = 'chat-1'; const page = 1; - final messagesFixture = FixtureReader.readAsMap('chat/chat_messages_response.json'); + final messagesFixture = FixtureReader.readAsMap( + 'chat/chat_messages_response.json', + ); final expectedMessages = ChatMessagesModel.fromJson(messagesFixture); - when(mockChatService.getChatMessages(id: chatId, page: page)) - .thenAnswer((_) async => expectedMessages); + when( + mockChatService.getChatMessages(id: chatId, page: page), + ).thenAnswer((_) async => expectedMessages); final result = await chatRepository.getChatMessages(chatId, page); @@ -206,42 +227,53 @@ void main() { verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1); }); - test('getChatRoomIdByUserSlug - получает комнату по slug пользователя', () async { - const slug = 'user-1'; - final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); - final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); + test( + 'getChatRoomIdByUserSlug - получает комнату по slug пользователя', + () async { + const slug = 'user-1'; + final roomFixture = FixtureReader.readAsMap( + 'chat/chat_room_response.json', + ); + final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); - when(mockChatService.getChatRoomIdByUserSlug(user: slug)) - .thenAnswer((_) async => expectedRoom); + when( + mockChatService.getChatRoomIdByUserSlug(user: slug), + ).thenAnswer((_) async => expectedRoom); - final result = await chatRepository.getChatRoomIdByUserSlug(slug: slug); + final result = await chatRepository.getChatRoomIdByUserSlug(slug: slug); - expect(result, isA()); - verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1); - }); + expect(result, isA()); + verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1); + }, + ); test('markMessagesAsRead - отмечает сообщения как прочитанные', () async { const chatId = 'chat-id-123'; final messageIds = [1, 2, 3]; - when(mockChatService.markMessagesAsRead( - id: chatId, - body: {'messages_ids': messageIds}, - )).thenAnswer((_) async => Future.value()); + when( + mockChatService.markMessagesAsRead( + id: chatId, + body: {'messages_ids': messageIds}, + ), + ).thenAnswer((_) async => Future.value()); await chatRepository.markMessagesAsRead(chatId, messageIds); - verify(mockChatService.markMessagesAsRead( - id: chatId, - body: {'messages_ids': messageIds}, - )).called(1); + verify( + mockChatService.markMessagesAsRead( + id: chatId, + body: {'messages_ids': messageIds}, + ), + ).called(1); }); test('cancelChosenPlan - отменяет выбранный план', () async { const planId = 123; - when(mockChatService.cancelChosenPlan(id: planId)) - .thenAnswer((_) async => Future.value()); + when( + mockChatService.cancelChosenPlan(id: planId), + ).thenAnswer((_) async => Future.value()); await chatRepository.cancelChosenPlan(id: planId); @@ -252,21 +284,27 @@ void main() { const planId = 123; final date = {'start_date': '2024-01-15'}; - when(mockChatService.choseStartPlanDate(id: planId, date: date)) - .thenAnswer((_) async => Future.value()); + when( + mockChatService.choseStartPlanDate(id: planId, date: date), + ).thenAnswer((_) async => Future.value()); await chatRepository.choseStartPlanDate(id: planId, date: date); - verify(mockChatService.choseStartPlanDate(id: planId, date: date)).called(1); + verify( + mockChatService.choseStartPlanDate(id: planId, date: date), + ).called(1); }); test('getPaymentLink - получает ссылку на оплату', () async { const planId = 123; - final paymentFixture = FixtureReader.readAsMap('chat/payment_response.json'); + final paymentFixture = FixtureReader.readAsMap( + 'chat/payments/payment_response.json', + ); final expectedPayment = PaymentModel.fromJson(paymentFixture); - when(mockChatService.getPaymentLink(id: planId)) - .thenAnswer((_) async => expectedPayment); + when( + mockChatService.getPaymentLink(id: planId), + ).thenAnswer((_) async => expectedPayment); final result = await chatRepository.getPaymentLink(id: planId); @@ -277,11 +315,14 @@ void main() { test('getChosenPlan - получает выбранный план', () async { const planId = 1; - final chosenPlanFixture = FixtureReader.readAsMap('chat/chosen_plan_response.json'); + final chosenPlanFixture = FixtureReader.readAsMap( + 'chat/chosen_plan_response.json', + ); final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture); - when(mockChatService.getChosenPlan(id: planId)) - .thenAnswer((_) async => expectedPlan); + when( + mockChatService.getChosenPlan(id: planId), + ).thenAnswer((_) async => expectedPlan); final result = await chatRepository.getChosenPlan(id: planId); @@ -293,63 +334,75 @@ void main() { test('finishChosenPlan - завершает выбранный план', () async { const planId = 123; - when(mockChatService.finishChosenPlan(id: planId)) - .thenAnswer((_) async => Future.value()); + when( + mockChatService.finishChosenPlan(id: planId), + ).thenAnswer((_) async => Future.value()); await chatRepository.finishChosenPlan(id: planId); verify(mockChatService.finishChosenPlan(id: planId)).called(1); }); - test('complainAboutConsultation - отправляет жалобу на консультацию', () async { - const planId = 123; - final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json'); - final complainModel = PlanComplainModel.fromJson(complainFixture); + test( + 'complainAboutConsultation - отправляет жалобу на консультацию', + () async { + const planId = 123; + final complainFixture = FixtureReader.readAsMap( + 'chat/plan_complain_response.json', + ); + final complainModel = PlanComplainModel.fromJson(complainFixture); - when(mockChatService.complainAboutConsultation(id: planId, body: complainModel)) - .thenAnswer((_) async => Future.value()); + when( + mockChatService.complainAboutConsultation( + id: planId, + body: complainModel, + ), + ).thenAnswer((_) async => Future.value()); - await chatRepository.complainAboutConsultation( - id: planId, - body: complainModel, - ); + await chatRepository.complainAboutConsultation( + id: planId, + body: complainModel, + ); - verify(mockChatService.complainAboutConsultation(id: planId, body: complainModel)) - .called(1); - }); + verify( + mockChatService.complainAboutConsultation( + id: planId, + body: complainModel, + ), + ).called(1); + }, + ); - test('getChatByUserSlug - возвращает ID чата при успешном ответе', () async { - const slug = 'user-slug'; - const chatId = 'chat-id-123'; - final response = HttpResponse( - {'id': chatId}, - Response( - requestOptions: RequestOptions(path: ''), - statusCode: 200, - ), - ); + test( + 'getChatByUserSlug - возвращает ID чата при успешном ответе', + () async { + const slug = 'user-slug'; + const chatId = 'chat-id-123'; + final response = HttpResponse({ + 'id': chatId, + }, Response(requestOptions: RequestOptions(path: ''), statusCode: 200)); - when(mockChatService.getChatByUserSlug(slug: slug)) - .thenAnswer((_) async => response); + when( + mockChatService.getChatByUserSlug(slug: slug), + ).thenAnswer((_) async => response); - final result = await chatRepository.getChatByUserSlug(slug); + final result = await chatRepository.getChatByUserSlug(slug); - expect(result, chatId); - verify(mockChatService.getChatByUserSlug(slug: slug)).called(1); - }); + expect(result, chatId); + verify(mockChatService.getChatByUserSlug(slug: slug)).called(1); + }, + ); test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async { const slug = 'user-slug'; final response = HttpResponse( {}, - Response( - requestOptions: RequestOptions(path: ''), - statusCode: 404, - ), + Response(requestOptions: RequestOptions(path: ''), statusCode: 404), ); - when(mockChatService.getChatByUserSlug(slug: slug)) - .thenAnswer((_) async => response); + when( + mockChatService.getChatByUserSlug(slug: slug), + ).thenAnswer((_) async => response); final result = await chatRepository.getChatByUserSlug(slug); @@ -357,17 +410,20 @@ void main() { verify(mockChatService.getChatByUserSlug(slug: slug)).called(1); }); - test('getChatByUserSlug - пробрасывает исключение при ошибке сети', () async { - const slug = 'user-slug'; + test( + 'getChatByUserSlug - пробрасывает исключение при ошибке сети', + () async { + const slug = 'user-slug'; - when(mockChatService.getChatByUserSlug(slug: slug)) - .thenThrow(Exception('Network error')); + when( + mockChatService.getChatByUserSlug(slug: slug), + ).thenThrow(Exception('Network error')); - await expectLater( - chatRepository.getChatByUserSlug(slug), - throwsA(isA()), - ); - }); + await expectLater( + chatRepository.getChatByUserSlug(slug), + throwsA(isA()), + ); + }, + ); }); } - diff --git a/test/features/chat/services/chat_service_test.dart b/test/features/chat/services/chat_service_test.dart index 3e4a924c..95c6cda9 100644 --- a/test/features/chat/services/chat_service_test.dart +++ b/test/features/chat/services/chat_service_test.dart @@ -1,7 +1,8 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; -import 'package:eatvio_app/features/chat/models/chat_room_model.dart' as chat_room; +import 'package:eatvio_app/features/chat/models/chat_room_model.dart' + as chat_room; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; @@ -24,10 +25,16 @@ void main() { }); test('getChatRooms - возвращает список комнат чата', () async { - final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); - final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); + final paginationFixture = FixtureReader.readAsMap( + 'chat/chat_room_pagination_response.json', + ); + final expectedPagination = ChatRoomPaginationModel.fromJson( + paginationFixture, + ); - when(mockChatService.getChatRooms(query: null, page: 1)).thenAnswer((_) async => expectedPagination); + when( + mockChatService.getChatRooms(query: null, page: 1), + ).thenAnswer((_) async => expectedPagination); final result = await mockChatService.getChatRooms(query: null, page: 1); @@ -38,10 +45,16 @@ void main() { test('getChatRooms - возвращает список комнат с запросом', () async { const query = 'test query'; - final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); - final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); + final paginationFixture = FixtureReader.readAsMap( + 'chat/chat_room_pagination_response.json', + ); + final expectedPagination = ChatRoomPaginationModel.fromJson( + paginationFixture, + ); - when(mockChatService.getChatRooms(query: query, page: 1)).thenAnswer((_) async => expectedPagination); + when( + mockChatService.getChatRooms(query: query, page: 1), + ).thenAnswer((_) async => expectedPagination); final result = await mockChatService.getChatRooms(query: query, page: 1); @@ -51,10 +64,14 @@ void main() { test('getChatRoom - возвращает комнату чата по UUID', () async { const uuid = 'chat-1'; - final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); + final roomFixture = FixtureReader.readAsMap( + 'chat/chat_room_response.json', + ); final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); - when(mockChatService.getChatRoom(uuid: uuid)).thenAnswer((_) async => expectedRoom); + when( + mockChatService.getChatRoom(uuid: uuid), + ).thenAnswer((_) async => expectedRoom); final result = await mockChatService.getChatRoom(uuid: uuid); @@ -67,62 +84,114 @@ void main() { const slug = 'user-slug'; const data = 'Test message'; const socketId = 'socket-id-123'; - final messageFixture = FixtureReader.readAsMap('chat/chat_message_response.json'); + final messageFixture = FixtureReader.readAsMap( + 'chat/chat_message_response.json', + ); final expectedMessage = ChatMessageModel.fromJson(messageFixture); // ignore: null_argument_to_non_null_type - when(mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId)).thenAnswer((_) async => expectedMessage); + when( + mockChatService.sendMessage( + slug: slug, + data: data, + mediaData: null, + socketId: socketId, + ), + ).thenAnswer((_) async => expectedMessage); - final result = await mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId); + final result = await mockChatService.sendMessage( + slug: slug, + data: data, + mediaData: null, + socketId: socketId, + ); expect(result, isA()); expect(result.data, data); - verify(mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId)).called(1); + verify( + mockChatService.sendMessage( + slug: slug, + data: data, + mediaData: null, + socketId: socketId, + ), + ).called(1); }); test('getChatMessages - возвращает сообщения чата', () async { const chatId = 'chat-1'; const page = 1; - final messagesFixture = FixtureReader.readAsMap('chat/chat_messages_response.json'); + final messagesFixture = FixtureReader.readAsMap( + 'chat/chat_messages_response.json', + ); final expectedMessages = ChatMessagesModel.fromJson(messagesFixture); - when(mockChatService.getChatMessages(id: chatId, page: page)).thenAnswer((_) async => expectedMessages); + when( + mockChatService.getChatMessages(id: chatId, page: page), + ).thenAnswer((_) async => expectedMessages); - final result = await mockChatService.getChatMessages(id: chatId, page: page); + final result = await mockChatService.getChatMessages( + id: chatId, + page: page, + ); expect(result, isA()); expect(result.messagesList.length, 2); verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1); }); - test('getChatRoomIdByUserSlug - возвращает комнату по slug пользователя', () async { - const slug = 'user-1'; - final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); - final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); + test( + 'getChatRoomIdByUserSlug - возвращает комнату по slug пользователя', + () async { + const slug = 'user-1'; + final roomFixture = FixtureReader.readAsMap( + 'chat/chat_room_response.json', + ); + final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); - when(mockChatService.getChatRoomIdByUserSlug(user: slug)).thenAnswer((_) async => expectedRoom); + when( + mockChatService.getChatRoomIdByUserSlug(user: slug), + ).thenAnswer((_) async => expectedRoom); - final result = await mockChatService.getChatRoomIdByUserSlug(user: slug); + final result = await mockChatService.getChatRoomIdByUserSlug( + user: slug, + ); - expect(result, isA()); - verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1); - }); + expect(result, isA()); + verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1); + }, + ); test('markMessagesAsRead - отмечает сообщения как прочитанные', () async { const chatId = 'chat-id-123'; final messageIds = [1, 2, 3]; - when(mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds})).thenAnswer((_) async => Future.value()); + when( + mockChatService.markMessagesAsRead( + id: chatId, + body: {'messages_ids': messageIds}, + ), + ).thenAnswer((_) async => Future.value()); - await mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds}); + await mockChatService.markMessagesAsRead( + id: chatId, + body: {'messages_ids': messageIds}, + ); - verify(mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds})).called(1); + verify( + mockChatService.markMessagesAsRead( + id: chatId, + body: {'messages_ids': messageIds}, + ), + ).called(1); }); test('cancelChosenPlan - отменяет выбранный план', () async { const planId = 123; - when(mockChatService.cancelChosenPlan(id: planId)).thenAnswer((_) async => Future.value()); + when( + mockChatService.cancelChosenPlan(id: planId), + ).thenAnswer((_) async => Future.value()); await mockChatService.cancelChosenPlan(id: planId); @@ -133,19 +202,27 @@ void main() { const planId = 123; final date = {'start_date': '2024-01-15'}; - when(mockChatService.choseStartPlanDate(id: planId, date: date)).thenAnswer((_) async => Future.value()); + when( + mockChatService.choseStartPlanDate(id: planId, date: date), + ).thenAnswer((_) async => Future.value()); await mockChatService.choseStartPlanDate(id: planId, date: date); - verify(mockChatService.choseStartPlanDate(id: planId, date: date)).called(1); + verify( + mockChatService.choseStartPlanDate(id: planId, date: date), + ).called(1); }); test('getPaymentLink - возвращает ссылку на оплату', () async { const planId = 123; - final paymentFixture = FixtureReader.readAsMap('chat/payment_response.json'); + final paymentFixture = FixtureReader.readAsMap( + 'chat/payments/payment_response.json', + ); final expectedPayment = PaymentModel.fromJson(paymentFixture); - when(mockChatService.getPaymentLink(id: planId)).thenAnswer((_) async => expectedPayment); + when( + mockChatService.getPaymentLink(id: planId), + ).thenAnswer((_) async => expectedPayment); final result = await mockChatService.getPaymentLink(id: planId); @@ -156,10 +233,14 @@ void main() { test('getChosenPlan - возвращает выбранный план', () async { const planId = 1; - final chosenPlanFixture = FixtureReader.readAsMap('chat/chosen_plan_response.json'); + final chosenPlanFixture = FixtureReader.readAsMap( + 'chat/chosen_plan_response.json', + ); final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture); - when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan); + when( + mockChatService.getChosenPlan(id: planId), + ).thenAnswer((_) async => expectedPlan); final result = await mockChatService.getChosenPlan(id: planId); @@ -171,31 +252,55 @@ void main() { test('finishChosenPlan - завершает выбранный план', () async { const planId = 123; - when(mockChatService.finishChosenPlan(id: planId)).thenAnswer((_) async => Future.value()); + when( + mockChatService.finishChosenPlan(id: planId), + ).thenAnswer((_) async => Future.value()); await mockChatService.finishChosenPlan(id: planId); verify(mockChatService.finishChosenPlan(id: planId)).called(1); }); - test('complainAboutConsultation - отправляет жалобу на консультацию', () async { - const planId = 123; - final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json'); - final complainModel = PlanComplainModel.fromJson(complainFixture); + test( + 'complainAboutConsultation - отправляет жалобу на консультацию', + () async { + const planId = 123; + final complainFixture = FixtureReader.readAsMap( + 'chat/plan_complain_response.json', + ); + final complainModel = PlanComplainModel.fromJson(complainFixture); - when(mockChatService.complainAboutConsultation(id: planId, body: complainModel)).thenAnswer((_) async => Future.value()); + when( + mockChatService.complainAboutConsultation( + id: planId, + body: complainModel, + ), + ).thenAnswer((_) async => Future.value()); - await mockChatService.complainAboutConsultation(id: planId, body: complainModel); + await mockChatService.complainAboutConsultation( + id: planId, + body: complainModel, + ); - verify(mockChatService.complainAboutConsultation(id: planId, body: complainModel)).called(1); - }); + verify( + mockChatService.complainAboutConsultation( + id: planId, + body: complainModel, + ), + ).called(1); + }, + ); test('getChatByUserSlug - возвращает HttpResponse', () async { const slug = 'user-slug'; const chatId = 'chat-id-123'; - final response = HttpResponse({'id': chatId}, Response(requestOptions: RequestOptions(path: ''), statusCode: 200)); + final response = HttpResponse({ + 'id': chatId, + }, Response(requestOptions: RequestOptions(path: ''), statusCode: 200)); - when(mockChatService.getChatByUserSlug(slug: slug)).thenAnswer((_) async => response); + when( + mockChatService.getChatByUserSlug(slug: slug), + ).thenAnswer((_) async => response); final result = await mockChatService.getChatByUserSlug(slug: slug); diff --git a/test/fixtures/chat/alfa_status_failed_response.json b/test/fixtures/chat/payments/alfa_status_failed_response.json similarity index 100% rename from test/fixtures/chat/alfa_status_failed_response.json rename to test/fixtures/chat/payments/alfa_status_failed_response.json diff --git a/test/fixtures/chat/alfa_status_pending_response.json b/test/fixtures/chat/payments/alfa_status_pending_response.json similarity index 100% rename from test/fixtures/chat/alfa_status_pending_response.json rename to test/fixtures/chat/payments/alfa_status_pending_response.json diff --git a/test/fixtures/chat/alfa_status_processing_response.json b/test/fixtures/chat/payments/alfa_status_processing_response.json similarity index 100% rename from test/fixtures/chat/alfa_status_processing_response.json rename to test/fixtures/chat/payments/alfa_status_processing_response.json diff --git a/test/fixtures/chat/alfa_status_success_response.json b/test/fixtures/chat/payments/alfa_status_success_response.json similarity index 100% rename from test/fixtures/chat/alfa_status_success_response.json rename to test/fixtures/chat/payments/alfa_status_success_response.json diff --git a/test/fixtures/chat/order_id_to_chat_uuid_response.json b/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json similarity index 100% rename from test/fixtures/chat/order_id_to_chat_uuid_response.json rename to test/fixtures/chat/payments/order_id_to_chat_uuid_response.json diff --git a/test/fixtures/chat/payment_alfabank_request.json b/test/fixtures/chat/payments/payment_alfabank_request.json similarity index 100% rename from test/fixtures/chat/payment_alfabank_request.json rename to test/fixtures/chat/payments/payment_alfabank_request.json diff --git a/test/fixtures/chat/payment_mock_requeste.json b/test/fixtures/chat/payments/payment_mock_requeste.json similarity index 100% rename from test/fixtures/chat/payment_mock_requeste.json rename to test/fixtures/chat/payments/payment_mock_requeste.json diff --git a/test/fixtures/chat/payment_response.json b/test/fixtures/chat/payments/payment_response.json similarity index 100% rename from test/fixtures/chat/payment_response.json rename to test/fixtures/chat/payments/payment_response.json diff --git a/test/fixtures/chat/payment_response_alfa_bank.json b/test/fixtures/chat/payments/payment_response_alfa_bank.json similarity index 100% rename from test/fixtures/chat/payment_response_alfa_bank.json rename to test/fixtures/chat/payments/payment_response_alfa_bank.json From d21cc465588275717a6086578ed1d5bb0d6aa1c6 Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Wed, 8 Jul 2026 13:31:55 +0300 Subject: [PATCH 06/16] feat(payments): phase #1 tests and part of implementation --- .../order_id_to_conversation_model.dart | 15 ++++ lib/features/chat/models/payment_model.dart | 8 +- .../bloc/chat_room_cubit/chat_room_cubit.dart | 2 +- .../chat/repositories/chat_repository.dart | 14 +++- lib/features/chat/services/chat_service.dart | 7 ++ .../order_info/cubit/order_info_cubit.dart | 2 +- .../order_id_to_conversation_model_test.dart | 22 ++++++ .../chat/models/payment_model_test.dart | 26 +++++-- .../bloc/chat_room_cubit_test.dart | 6 +- .../repositories/chat_repository_test.dart | 76 ++++++++++++++++++- .../chat/services/chat_service_test.dart | 35 ++++++++- .../cubit/order_info_cubit_test.dart | 8 +- 12 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 lib/features/chat/models/order_id_to_conversation_model.dart create mode 100644 test/features/chat/models/order_id_to_conversation_model_test.dart diff --git a/lib/features/chat/models/order_id_to_conversation_model.dart b/lib/features/chat/models/order_id_to_conversation_model.dart new file mode 100644 index 00000000..8e83a12c --- /dev/null +++ b/lib/features/chat/models/order_id_to_conversation_model.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'order_id_to_conversation_model.freezed.dart'; +part 'order_id_to_conversation_model.g.dart'; + +@freezed +abstract class OrderIdToConversationModel with _$OrderIdToConversationModel { + factory OrderIdToConversationModel({ + @JsonKey(name: 'chosen_plan_id') required int chosenPlanId, + @JsonKey(name: 'conversation_uuid') required String conversationUuid, + }) = _OrderIdToConversationModel; + + factory OrderIdToConversationModel.fromJson(Map json) => + _$OrderIdToConversationModelFromJson(json); +} diff --git a/lib/features/chat/models/payment_model.dart b/lib/features/chat/models/payment_model.dart index e2c5fbba..6413d5df 100644 --- a/lib/features/chat/models/payment_model.dart +++ b/lib/features/chat/models/payment_model.dart @@ -5,10 +5,12 @@ part 'payment_model.g.dart'; @freezed abstract class PaymentModel with _$PaymentModel { - factory PaymentModel({ @JsonKey(name: 'checkout_url') required String paymentUrl, + @JsonKey(name: 'order_id') String? orderId, + @JsonKey(name: 'payment_provider') String? paymentProvider, }) = _PaymentModel; - factory PaymentModel.fromJson(Map json) => _$PaymentModelFromJson(json); -} \ No newline at end of file + factory PaymentModel.fromJson(Map json) => + _$PaymentModelFromJson(json); +} diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index dc56a0c1..bb4a8ebd 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -297,7 +297,7 @@ class ChatRoomCubit extends Cubit { try { final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; if (planId == null) return null; - final paymentModel = await chatRepository.getPaymentLink(id: planId); + final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank'); return paymentModel.paymentUrl; } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/chat/repositories/chat_repository.dart b/lib/features/chat/repositories/chat_repository.dart index fc079241..ade2ed26 100644 --- a/lib/features/chat/repositories/chat_repository.dart +++ b/lib/features/chat/repositories/chat_repository.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; +import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/services/chat_service.dart'; @@ -98,8 +99,19 @@ class ChatRepository { Future getPaymentLink({ required int id, + required String paymentProvider, }) async { - return await chatService.getPaymentLink(id: id); + return await chatService.getPaymentLink( + id: id, + body: {'payment_provider': paymentProvider}, + ); + + } + + Future resolveOrderIdToConversation({ + required String orderId, + }) async { + return await chatService.resolveOrderIdToConversation(orderId: orderId); } diff --git a/lib/features/chat/services/chat_service.dart b/lib/features/chat/services/chat_service.dart index a2bf52cf..2d5ceaac 100644 --- a/lib/features/chat/services/chat_service.dart +++ b/lib/features/chat/services/chat_service.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; +import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; @@ -65,6 +66,12 @@ abstract class ChatService { @POST('/api/chosen_plans/{id}/start_payment_process') Future getPaymentLink({ @Path() required int id, + @Body() required Map body, + }); + + @GET('/api/consultation/resolve/order_id_to_conversation/{orderId}') + Future resolveOrderIdToConversation({ + @Path() required String orderId, }); @GET('/api/chosen_plans/show/{id}') diff --git a/lib/features/order_info/cubit/order_info_cubit.dart b/lib/features/order_info/cubit/order_info_cubit.dart index ff331c01..1c1630b6 100644 --- a/lib/features/order_info/cubit/order_info_cubit.dart +++ b/lib/features/order_info/cubit/order_info_cubit.dart @@ -14,7 +14,7 @@ class OrderInfoCubit extends Cubit { try { final plan = state.chosenPlan; if (plan == null) return null; - final paymentModel = await chatRepository.getPaymentLink(id: plan.id); + final paymentModel = await chatRepository.getPaymentLink(id: plan.id, paymentProvider: 'alfa-bank'); return paymentModel.paymentUrl; } catch (e, st) { await reportFrom(e, st, this); diff --git a/test/features/chat/models/order_id_to_conversation_model_test.dart b/test/features/chat/models/order_id_to_conversation_model_test.dart new file mode 100644 index 00000000..d64441b7 --- /dev/null +++ b/test/features/chat/models/order_id_to_conversation_model_test.dart @@ -0,0 +1,22 @@ +import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/fixture_reader.dart'; + +void main() { + group('OrderIdToConversationModel', () { + test('fromJson создаёт модель из JSON фикстуры', () { + final json = FixtureReader.readAsMap( + 'chat/payments/order_id_to_chat_uuid_response.json', + ); + + final model = OrderIdToConversationModel.fromJson(json); + + expect(model.chosenPlanId, 12); + expect( + model.conversationUuid, + 'fbd81429-8019-499a-80e8-e3933cc4d872', + ); + }); + }); +} diff --git a/test/features/chat/models/payment_model_test.dart b/test/features/chat/models/payment_model_test.dart index 8e26a01e..59675ab0 100644 --- a/test/features/chat/models/payment_model_test.dart +++ b/test/features/chat/models/payment_model_test.dart @@ -1,25 +1,37 @@ import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../fixtures/fixture_reader.dart'; + void main() { group('PaymentModel', () { test('создает PaymentModel с правильными значениями', () { - final model = PaymentModel( - paymentUrl: 'https://example.com/payment', - ); + final model = PaymentModel(paymentUrl: 'https://example.com/payment'); expect(model.paymentUrl, 'https://example.com/payment'); }); test('fromJson создает PaymentModel из JSON', () { - final json = { - 'checkout_url': 'https://example.com/payment', - }; + final json = {'checkout_url': 'https://example.com/payment'}; final model = PaymentModel.fromJson(json); expect(model.paymentUrl, 'https://example.com/payment'); }); + + test( + 'fromJson парсит ответ alfa-bank (checkout_url, order_id, payment_provider)', + () { + final json = FixtureReader.readAsMap( + 'chat/payments/payment_response_alfa_bank.json', + ); + + final model = PaymentModel.fromJson(json); + + expect(model.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF'); + expect(model.orderId, 'alfa_c29_k15138_t1712345678_r1234'); + expect(model.paymentProvider, 'alfa-bank'); + }, + ); }); } - diff --git a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart index 9dd43523..b59f16d8 100644 --- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart @@ -678,7 +678,7 @@ void main() { when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.getPaymentLink(id: planId)).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl)); + when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'mock')).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl)); when(mockWsManager.client).thenReturn(null); when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); @@ -691,7 +691,7 @@ void main() { final result = await chatRoomCubit.getPaymentLink(); expect(result, paymentUrl); - verify(mockChatRepository.getPaymentLink(id: planId)).called(1); + verify(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'mock')).called(1); }); test('getPaymentLink - обрабатывает ошибку', () async { @@ -730,7 +730,7 @@ void main() { when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.getPaymentLink(id: planId)).thenThrow(Exception('Payment error')); + when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'mock')).thenThrow(Exception('Payment error')); when(mockWsManager.client).thenReturn(null); when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart index f60e028e..0fb35bf4 100644 --- a/test/features/chat/repositories/chat_repository_test.dart +++ b/test/features/chat/repositories/chat_repository_test.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart' as chat_room; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; +import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; @@ -303,14 +304,83 @@ void main() { final expectedPayment = PaymentModel.fromJson(paymentFixture); when( - mockChatService.getPaymentLink(id: planId), + mockChatService.getPaymentLink( + id: planId, + body: {'payment_provider': 'mock'}, + ), ).thenAnswer((_) async => expectedPayment); - final result = await chatRepository.getPaymentLink(id: planId); + final result = await chatRepository.getPaymentLink( + id: planId, + paymentProvider: 'mock', + ); expect(result, isA()); expect(result.paymentUrl, 'https://example.com/checkout/payment-123'); - verify(mockChatService.getPaymentLink(id: planId)).called(1); + verify( + mockChatService.getPaymentLink( + id: planId, + body: {'payment_provider': 'mock'}, + ), + ).called(1); + }); + + test( + 'getPaymentLink - передаёт payment_provider в тело запроса alfa-bank', + () async { + const planId = 123; + 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); + + final result = await chatRepository.getPaymentLink( + id: planId, + paymentProvider: 'alfa-bank', + ); + + expect(result, isA()); + 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('resolveOrderIdToConversation - получает модель по orderId', () async { + const orderId = 'alfa_c29_k15138_t1712345678_r1234'; + final fixture = FixtureReader.readAsMap( + 'chat/payments/order_id_to_chat_uuid_response.json', + ); + final expected = OrderIdToConversationModel.fromJson(fixture); + + when( + mockChatService.resolveOrderIdToConversation(orderId: orderId), + ).thenAnswer((_) async => expected); + + final result = await chatRepository.resolveOrderIdToConversation( + orderId: orderId, + ); + + expect(result, isA()); + expect(result.chosenPlanId, 12); + expect(result.conversationUuid, "fbd81429-8019-499a-80e8-e3933cc4d872"); + + verify( + mockChatService.resolveOrderIdToConversation(orderId: orderId), + ).called(1); }); test('getChosenPlan - получает выбранный план', () async { diff --git a/test/features/chat/services/chat_service_test.dart b/test/features/chat/services/chat_service_test.dart index 95c6cda9..68750a7e 100644 --- a/test/features/chat/services/chat_service_test.dart +++ b/test/features/chat/services/chat_service_test.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart' as chat_room; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; +import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/services/chat_service.dart'; @@ -221,14 +222,17 @@ void main() { final expectedPayment = PaymentModel.fromJson(paymentFixture); when( - mockChatService.getPaymentLink(id: planId), + mockChatService.getPaymentLink(id: planId, body: {'payment_provider': 'mock'}), ).thenAnswer((_) async => expectedPayment); - final result = await mockChatService.getPaymentLink(id: planId); + final result = await mockChatService.getPaymentLink( + id: planId, + body: {'payment_provider': 'mock'}, + ); expect(result, isA()); expect(result.paymentUrl, 'https://example.com/checkout/payment-123'); - verify(mockChatService.getPaymentLink(id: planId)).called(1); + verify(mockChatService.getPaymentLink(id: planId, body: {'payment_provider': 'mock'})).called(1); }); test('getChosenPlan - возвращает выбранный план', () async { @@ -291,6 +295,31 @@ void main() { }, ); + test( + 'resolveOrderIdToConversation - возвращает модель по orderId', + () async { + const orderId = 'alfa_c29_k15138_t1712345678_r1234'; + final fixture = FixtureReader.readAsMap( + 'chat/payments/order_id_to_chat_uuid_response.json', + ); + final expected = OrderIdToConversationModel.fromJson(fixture); + + when( + mockChatService.resolveOrderIdToConversation(orderId: orderId), + ).thenAnswer((_) async => expected); + + final result = await mockChatService.resolveOrderIdToConversation( + orderId: orderId, + ); + + expect(result, isA()); + expect(result.chosenPlanId, 12); + verify( + mockChatService.resolveOrderIdToConversation(orderId: orderId), + ).called(1); + }, + ); + test('getChatByUserSlug - возвращает HttpResponse', () async { const slug = 'user-slug'; const chatId = 'chat-id-123'; diff --git a/test/features/order_info/cubit/order_info_cubit_test.dart b/test/features/order_info/cubit/order_info_cubit_test.dart index de00ad94..b1925972 100644 --- a/test/features/order_info/cubit/order_info_cubit_test.dart +++ b/test/features/order_info/cubit/order_info_cubit_test.dart @@ -200,20 +200,20 @@ void main() { .thenAnswer((_) async => chosenPlan); await cubit.getChosenPlan(id: 1); - when(mockChatRepository.getPaymentLink(id: 1)) + when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'mock')) .thenAnswer((_) async => paymentModel); final result = await cubit.getPaymentLink(); expect(result, equals('https://payment.url')); - verify(mockChatRepository.getPaymentLink(id: 1)).called(1); + verify(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'mock')).called(1); }); test('getPaymentLink - возвращает null, если план не выбран', () async { final result = await cubit.getPaymentLink(); expect(result, isNull); - verifyNever(mockChatRepository.getPaymentLink(id: anyNamed('id'))); + verifyNever(mockChatRepository.getPaymentLink(id: anyNamed('id'), paymentProvider: anyNamed('paymentProvider'))); }); test('getPaymentLink - обработка ошибки', () async { @@ -254,7 +254,7 @@ void main() { .thenAnswer((_) async => chosenPlan); await cubit.getChosenPlan(id: 1); - when(mockChatRepository.getPaymentLink(id: 1)) + when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'mock')) .thenThrow(Exception('Ошибка')); final result = await cubit.getPaymentLink(); From e10610d93c3af0e9b890160f1496f5da2d7680ef Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 8 Jul 2026 14:28:08 +0300 Subject: [PATCH 07/16] feat(sbp_payments): added new chosen_plan fixtures with chosen_plan states and fixed chat tests --- .../repositories/chat_repository_test.dart | 2 +- .../chat/services/chat_service_test.dart | 2 +- ...an_canceled_by_the_initiator_response.json | 47 +++++++++++++++++++ ...en_plan_charge_was_callected_response.json | 47 +++++++++++++++++++ .../chosen_plan_complained_response.json | 47 +++++++++++++++++++ ...plan_confirmed_by_consultant_response.json | 47 +++++++++++++++++++ .../chosen_plan_done_response.json | 47 +++++++++++++++++++ ...chosen_plan_in_coordination_response.json} | 0 .../chosen_plan_in_progress_response.json | 47 +++++++++++++++++++ .../chosen_plan_initied_response.json | 47 +++++++++++++++++++ ...hosen_plan_last_day_finished_response.json | 47 +++++++++++++++++++ ...chosen_plan_last_day_started_response.json | 47 +++++++++++++++++++ .../chosen_plan_paid_response.json | 47 +++++++++++++++++++ .../chosen_plan_payment_failed_response.json | 47 +++++++++++++++++++ .../chosen_plan_pending_payment_response.json | 47 +++++++++++++++++++ ...hosen_plan_presonal_diet_set_response.json | 47 +++++++++++++++++++ ...plan_start_date_has_been_set_response.json | 47 +++++++++++++++++++ .../chosen_plan_started_response.json | 47 +++++++++++++++++++ 18 files changed, 707 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_charge_was_callected_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_complained_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_confirmed_by_consultant_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_done_response.json rename test/fixtures/chat/{chosen_plan_response.json => chosen_plan/chosen_plan_in_coordination_response.json} (100%) create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_in_progress_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_initied_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_last_day_finished_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_last_day_started_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_paid_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_payment_failed_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_pending_payment_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_presonal_diet_set_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_start_date_has_been_set_response.json create mode 100644 test/fixtures/chat/chosen_plan/chosen_plan_started_response.json diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart index 0fb35bf4..12e75a64 100644 --- a/test/features/chat/repositories/chat_repository_test.dart +++ b/test/features/chat/repositories/chat_repository_test.dart @@ -386,7 +386,7 @@ void main() { test('getChosenPlan - получает выбранный план', () async { const planId = 1; final chosenPlanFixture = FixtureReader.readAsMap( - 'chat/chosen_plan_response.json', + 'chat/chosen_plan/chosen_plan_in_progress_response.json', ); final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture); diff --git a/test/features/chat/services/chat_service_test.dart b/test/features/chat/services/chat_service_test.dart index 68750a7e..bf55d616 100644 --- a/test/features/chat/services/chat_service_test.dart +++ b/test/features/chat/services/chat_service_test.dart @@ -238,7 +238,7 @@ void main() { test('getChosenPlan - возвращает выбранный план', () async { const planId = 1; final chosenPlanFixture = FixtureReader.readAsMap( - 'chat/chosen_plan_response.json', + 'chat/chosen_plan/chosen_plan_in_progress_response.json', ); final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture); diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json new file mode 100644 index 00000000..95cd055e --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "canceled_by_the_initiator", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_charge_was_callected_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_charge_was_callected_response.json new file mode 100644 index 00000000..8b105150 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_charge_was_callected_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "charge_was_collected", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_complained_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_complained_response.json new file mode 100644 index 00000000..afc390cf --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_complained_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "complained", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_confirmed_by_consultant_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_confirmed_by_consultant_response.json new file mode 100644 index 00000000..c5f7eac3 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_confirmed_by_consultant_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "confirmed_by_consultant", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_done_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_done_response.json new file mode 100644 index 00000000..a119dbaa --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_done_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "done", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_in_coordination_response.json similarity index 100% rename from test/fixtures/chat/chosen_plan_response.json rename to test/fixtures/chat/chosen_plan/chosen_plan_in_coordination_response.json diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_in_progress_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_in_progress_response.json new file mode 100644 index 00000000..f50410b9 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_in_progress_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "in_progress", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_initied_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_initied_response.json new file mode 100644 index 00000000..3c2773ba --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_initied_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "initied", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_last_day_finished_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_last_day_finished_response.json new file mode 100644 index 00000000..9c60bed1 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_last_day_finished_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "last_day_finished", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_last_day_started_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_last_day_started_response.json new file mode 100644 index 00000000..7e42fc0f --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_last_day_started_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "last_day_started", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_paid_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_paid_response.json new file mode 100644 index 00000000..e9c1fbd0 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_paid_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "paid", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_payment_failed_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_payment_failed_response.json new file mode 100644 index 00000000..7857a970 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_payment_failed_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "payment_failed", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_pending_payment_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_pending_payment_response.json new file mode 100644 index 00000000..9a1a8529 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_pending_payment_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "pending_payment", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_presonal_diet_set_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_presonal_diet_set_response.json new file mode 100644 index 00000000..317faa83 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_presonal_diet_set_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "personal_diet_setted", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_start_date_has_been_set_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_start_date_has_been_set_response.json new file mode 100644 index 00000000..d30e9b58 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_start_date_has_been_set_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "start_date_has_been_set", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + diff --git a/test/fixtures/chat/chosen_plan/chosen_plan_started_response.json b/test/fixtures/chat/chosen_plan/chosen_plan_started_response.json new file mode 100644 index 00000000..fe3584e2 --- /dev/null +++ b/test/fixtures/chat/chosen_plan/chosen_plan_started_response.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "option_type": "1", + "start_date": "2024-01-01T00:00:00Z", + "last_state": "started", + "chat_id": "chat-1", + "consultant_id": 1, + "plan": { + "id": 1, + "slug": "test-plan", + "name": "Test Plan", + "image": "https://example.com/plan.jpg", + "duration": 30, + "state": "active", + "type": { + "fit": true, + "slim": false, + "behavior": false, + "wellness": false + }, + "description_md": "Plan description", + "packets_count": 1, + "packet1_options": { + "items": ["Item 1", "Item 2"], + "price": 1500.0, + "short": "Option 1", + "description_md": "Description", + "has_personal_diet": false + }, + "packet2_options": null, + "packet3_options": null, + "author": { + "author_slug": "author-1", + "author_name": "Author Name", + "author_profile_icon": "https://example.com/author.jpg", + "author_is_me": false + }, + "total_likes": 10, + "total_views": 100, + "total_comments": 5, + "is_favorite": false, + "min_price_of_options": 1000.0, + "max_price_of_options": 2000.0, + "was_liked_by_me": false + } +} + From 51397003b58c47bf7c821f368b6af13dc5837f52 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 8 Jul 2026 15:07:36 +0300 Subject: [PATCH 08/16] feat(sbp_payments): added tests for new chosen_plan_state fixtures and new tests for chat repo and service --- .../plans/enums/chosen_plan_state_enum.dart | 7 ++- .../chat/models/payment_model_test.dart | 10 +++ .../repositories/chat_repository_test.dart | 63 +++++++++++++++++++ .../chat/services/chat_service_test.dart | 32 ++++++++++ .../enums/chosen_plan_state_enum_test.dart | 6 +- .../plans/models/chosen_plan_model_test.dart | 56 +++++++++++++++++ .../payments/alfa_status_failed_response.json | 6 -- .../alfa_status_pending_response.json | 6 -- .../alfa_status_processing_response.json | 6 -- .../alfa_status_success_response.json | 6 -- 10 files changed, 171 insertions(+), 27 deletions(-) delete mode 100644 test/fixtures/chat/payments/alfa_status_failed_response.json delete mode 100644 test/fixtures/chat/payments/alfa_status_pending_response.json delete mode 100644 test/fixtures/chat/payments/alfa_status_processing_response.json delete mode 100644 test/fixtures/chat/payments/alfa_status_success_response.json diff --git a/lib/features/plans/enums/chosen_plan_state_enum.dart b/lib/features/plans/enums/chosen_plan_state_enum.dart index 2f37ec30..2e867f38 100644 --- a/lib/features/plans/enums/chosen_plan_state_enum.dart +++ b/lib/features/plans/enums/chosen_plan_state_enum.dart @@ -30,7 +30,9 @@ enum ChosenPlanStateEnum { @JsonValue('personal_diet_setted') personalDietSet, @JsonValue('charge_was_collected') - stateChargeWasCollected; + stateChargeWasCollected, + @JsonValue('payment_failed') + statePaymentFailed; static List getShowableChatBadgeStates() { return [ @@ -53,6 +55,7 @@ enum ChosenPlanStateEnum { case ChosenPlanStateEnum.stateInCoordination: case ChosenPlanStateEnum.stateStartDateHasBeenSet: case ChosenPlanStateEnum.statePendingPayment: + case ChosenPlanStateEnum.statePaymentFailed: return false; default: return true; @@ -68,7 +71,7 @@ enum ChosenPlanStateEnum { ChosenPlanStateEnum.stateLastDayFinished, ChosenPlanStateEnum.stateComplained, ChosenPlanStateEnum.stateDone, - ChosenPlanStateEnum.personalDietSet + ChosenPlanStateEnum.personalDietSet, ]; } } diff --git a/test/features/chat/models/payment_model_test.dart b/test/features/chat/models/payment_model_test.dart index 59675ab0..12019bff 100644 --- a/test/features/chat/models/payment_model_test.dart +++ b/test/features/chat/models/payment_model_test.dart @@ -33,5 +33,15 @@ void main() { expect(model.paymentProvider, 'alfa-bank'); }, ); + + test('fromJson парсит mock-ответ (checkout_url без order_id)', () { + final json = FixtureReader.readAsMap('chat/payments/payment_response.json'); + + final model = PaymentModel.fromJson(json); + + expect(model.paymentUrl, 'https://example.com/checkout/payment-123'); + expect(model.orderId, isNull); + expect(model.paymentProvider, isNull); + }); }); } diff --git a/test/features/chat/repositories/chat_repository_test.dart b/test/features/chat/repositories/chat_repository_test.dart index 12e75a64..247d1311 100644 --- a/test/features/chat/repositories/chat_repository_test.dart +++ b/test/features/chat/repositories/chat_repository_test.dart @@ -13,6 +13,7 @@ import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/services/chat_service.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:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; @@ -401,6 +402,68 @@ void main() { verify(mockChatService.getChosenPlan(id: planId)).called(1); }); + group('getChosenPlan payment polling statuses', () { + test('getChosenPlan - возвращает план в статусе pending_payment', () async { + const planId = 1; + final fixture = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_pending_payment_response.json', + ); + final expectedPlan = ChosenPlanModel.fromJson(fixture); + + when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan); + + final result = await chatRepository.getChosenPlan(id: planId); + + expect(result.chosenPlanState, ChosenPlanStateEnum.statePendingPayment); + verify(mockChatService.getChosenPlan(id: planId)).called(1); + }); + + test('getChosenPlan - возвращает план в статусе paid', () async { + const planId = 1; + final fixture = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_paid_response.json', + ); + final expectedPlan = ChosenPlanModel.fromJson(fixture); + + when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan); + + final result = await chatRepository.getChosenPlan(id: planId); + + expect(result.chosenPlanState, ChosenPlanStateEnum.statePaid); + verify(mockChatService.getChosenPlan(id: planId)).called(1); + }); + + test('getChosenPlan - возвращает план в статусе payment_failed', () async { + const planId = 1; + final fixture = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_payment_failed_response.json', + ); + final expectedPlan = ChosenPlanModel.fromJson(fixture); + + when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan); + + final result = await chatRepository.getChosenPlan(id: planId); + + expect(result.chosenPlanState, ChosenPlanStateEnum.statePaymentFailed); + verify(mockChatService.getChosenPlan(id: planId)).called(1); + }); + + test('getChosenPlan - возвращает план в статусе canceled_by_the_initiator', () async { + const planId = 1; + final fixture = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json', + ); + final expectedPlan = ChosenPlanModel.fromJson(fixture); + + when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan); + + final result = await chatRepository.getChosenPlan(id: planId); + + expect(result.chosenPlanState, ChosenPlanStateEnum.stateCanceledByTheInitiator); + verify(mockChatService.getChosenPlan(id: planId)).called(1); + }); + }); + test('finishChosenPlan - завершает выбранный план', () async { const planId = 123; diff --git a/test/features/chat/services/chat_service_test.dart b/test/features/chat/services/chat_service_test.dart index bf55d616..d544e6a2 100644 --- a/test/features/chat/services/chat_service_test.dart +++ b/test/features/chat/services/chat_service_test.dart @@ -235,6 +235,38 @@ void main() { verify(mockChatService.getPaymentLink(id: planId, body: {'payment_provider': 'mock'})).called(1); }); + test( + 'getPaymentLink - возвращает ссылку на оплату alfa-bank', + () async { + const planId = 123; + final requestFixture = FixtureReader.readAsMap( + 'chat/payments/payment_alfabank_request.json', + ); + final requestBody = Map.from(requestFixture); + final paymentFixture = FixtureReader.readAsMap( + 'chat/payments/payment_response_alfa_bank.json', + ); + final expectedPayment = PaymentModel.fromJson(paymentFixture); + + when( + mockChatService.getPaymentLink(id: planId, body: requestBody), + ).thenAnswer((_) async => expectedPayment); + + final result = await mockChatService.getPaymentLink( + id: planId, + body: requestBody, + ); + + expect(result, isA()); + 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: requestBody), + ).called(1); + }, + ); + test('getChosenPlan - возвращает выбранный план', () async { const planId = 1; final chosenPlanFixture = FixtureReader.readAsMap( diff --git a/test/features/plans/enums/chosen_plan_state_enum_test.dart b/test/features/plans/enums/chosen_plan_state_enum_test.dart index 104bfa59..3c963836 100644 --- a/test/features/plans/enums/chosen_plan_state_enum_test.dart +++ b/test/features/plans/enums/chosen_plan_state_enum_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('ChosenPlanStateEnum', () { test('has all expected values', () { - expect(ChosenPlanStateEnum.values.length, 15); + expect(ChosenPlanStateEnum.values.length, 16); expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateInited)); expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateStartDateHasBeenSet)); expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateInCoordination)); @@ -20,6 +20,7 @@ void main() { expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateDone)); expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.personalDietSet)); expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateChargeWasCollected)); + expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.statePaymentFailed)); }); test('getShowableChatBadgeStates returns correct states', () { @@ -35,6 +36,7 @@ void main() { expect(states, contains(ChosenPlanStateEnum.stateDone)); expect(states, contains(ChosenPlanStateEnum.stateInProgress)); expect(states, contains(ChosenPlanStateEnum.personalDietSet)); + expect(states, isNot(contains(ChosenPlanStateEnum.statePaymentFailed))); }); test('isStateAfterPaidState returns false for states before paid', () { @@ -43,6 +45,7 @@ void main() { expect(ChosenPlanStateEnum.stateInCoordination.isStateAfterPaidState(), isFalse); expect(ChosenPlanStateEnum.stateStartDateHasBeenSet.isStateAfterPaidState(), isFalse); expect(ChosenPlanStateEnum.statePendingPayment.isStateAfterPaidState(), isFalse); + expect(ChosenPlanStateEnum.statePaymentFailed.isStateAfterPaidState(), isFalse); }); test('isStateAfterPaidState returns true for states after paid', () { @@ -70,6 +73,7 @@ void main() { expect(activeStates, contains(ChosenPlanStateEnum.stateComplained)); expect(activeStates, contains(ChosenPlanStateEnum.stateDone)); expect(activeStates, contains(ChosenPlanStateEnum.personalDietSet)); + expect(activeStates, isNot(contains(ChosenPlanStateEnum.statePaymentFailed))); }); }); } diff --git a/test/features/plans/models/chosen_plan_model_test.dart b/test/features/plans/models/chosen_plan_model_test.dart index c40dd0cc..a3100298 100644 --- a/test/features/plans/models/chosen_plan_model_test.dart +++ b/test/features/plans/models/chosen_plan_model_test.dart @@ -4,6 +4,8 @@ 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:flutter_test/flutter_test.dart'; +import '../../../fixtures/fixture_reader.dart'; + void main() { group('ChosenPlanModel', () { test('создает ChosenPlanModel с правильными значениями', () { @@ -140,6 +142,60 @@ void main() { expect(json['consultant_id'], 1); expect(json.containsKey('plan'), isTrue); }); + + group('fromJson payment polling statuses', () { + test('fromJson парсит pending_payment из фикстуры', () { + final json = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_pending_payment_response.json', + ); + + final model = ChosenPlanModel.fromJson(json); + + expect(model.id, 1); + expect(model.optionType, '1'); + expect(model.chosenPlanState, ChosenPlanStateEnum.statePendingPayment); + expect(model.chatId, 'chat-1'); + }); + + test('fromJson парсит paid из фикстуры', () { + final json = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_paid_response.json', + ); + + final model = ChosenPlanModel.fromJson(json); + + expect(model.id, 1); + expect(model.optionType, '1'); + expect(model.chosenPlanState, ChosenPlanStateEnum.statePaid); + expect(model.chatId, 'chat-1'); + }); + + test('fromJson парсит payment_failed из фикстуры', () { + final json = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_payment_failed_response.json', + ); + + final model = ChosenPlanModel.fromJson(json); + + expect(model.id, 1); + expect(model.optionType, '1'); + expect(model.chosenPlanState, ChosenPlanStateEnum.statePaymentFailed); + expect(model.chatId, 'chat-1'); + }); + + test('fromJson парсит canceled_by_the_initiator из фикстуры', () { + final json = FixtureReader.readAsMap( + 'chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json', + ); + + final model = ChosenPlanModel.fromJson(json); + + expect(model.id, 1); + expect(model.optionType, '1'); + expect(model.chosenPlanState, ChosenPlanStateEnum.stateCanceledByTheInitiator); + expect(model.chatId, 'chat-1'); + }); + }); }); group('parseChosenPlan', () { diff --git a/test/fixtures/chat/payments/alfa_status_failed_response.json b/test/fixtures/chat/payments/alfa_status_failed_response.json deleted file mode 100644 index 934b0487..00000000 --- a/test/fixtures/chat/payments/alfa_status_failed_response.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order_id": "alfa_c29_k15138_t1712345678_r1234", - "status": "failed", - "is_paid": false, - "is_expired": false -} diff --git a/test/fixtures/chat/payments/alfa_status_pending_response.json b/test/fixtures/chat/payments/alfa_status_pending_response.json deleted file mode 100644 index d928665f..00000000 --- a/test/fixtures/chat/payments/alfa_status_pending_response.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order_id": "alfa_c29_k15138_t1712345678_r1234", - "status": "pending", - "is_paid": false, - "is_expired": false -} diff --git a/test/fixtures/chat/payments/alfa_status_processing_response.json b/test/fixtures/chat/payments/alfa_status_processing_response.json deleted file mode 100644 index 08cb35c3..00000000 --- a/test/fixtures/chat/payments/alfa_status_processing_response.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order_id": "alfa_c29_k15138_t1712345678_r1234", - "status": "processing", - "is_paid": false, - "is_expired": false -} diff --git a/test/fixtures/chat/payments/alfa_status_success_response.json b/test/fixtures/chat/payments/alfa_status_success_response.json deleted file mode 100644 index 53be4cae..00000000 --- a/test/fixtures/chat/payments/alfa_status_success_response.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "order_id": "alfa_c29_k15138_t1712345678_r1234", - "status": "success", - "is_paid": true, - "is_expired": false -} From a0edfe942b5f7eb87f848e599e83caaf4948e4d9 Mon Sep 17 00:00:00 2001 From: Anatoliy Poloz Date: Thu, 9 Jul 2026 16:21:53 +0300 Subject: [PATCH 09/16] feat(payments): phase 3 test added --- Makefile | 2 +- lib/core/di/repository_registrations.dart | 2 + lib/core/di/service_registrations.dart | 2 + .../payment/repositories/sbp_repository.dart | 27 ++++ .../payment/services/sbp_service.dart | 43 +++++++ .../repositories/sbp_repository_test.dart | 118 +++++++++++++++++ .../payment/services/sbp_service_test.dart | 119 ++++++++++++++++++ 7 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 lib/features/payment/repositories/sbp_repository.dart create mode 100644 lib/features/payment/services/sbp_service.dart create mode 100644 test/features/payment/repositories/sbp_repository_test.dart create mode 100644 test/features/payment/services/sbp_service_test.dart 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); + }); + }); +} From be924265a152aef2d8cef25887eeecba446fcbe6 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 9 Jul 2026 18:35:09 +0300 Subject: [PATCH 10/16] feat(sbp_payments): added cubit layer for sbp payments with tests --- lib/core/di/cubit_registrations.dart | 4 + lib/core/di/repository_registrations.dart | 2 + .../consultation_payment_cubit.dart | 98 +++++++++ .../consultation_payment_state.dart | 15 ++ .../repositories/payment_repository.dart | 18 ++ .../consultation_payment_cubit_test.dart | 188 ++++++++++++++++++ .../repositories/payment_repository_test.dart | 97 +++++++++ 7 files changed, 422 insertions(+) create mode 100644 lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart create mode 100644 lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart create mode 100644 lib/features/payment/repositories/payment_repository.dart create mode 100644 test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart create mode 100644 test/features/payment/repositories/payment_repository_test.dart diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 6f26ab95..e5a1f91b 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -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(repository: sl())); sl.registerFactory(() => UserCardsCubit(cardsRepository: sl())); sl.registerFactory(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl())); + sl.registerFactory( + () => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()), + ); sl.registerFactory(() => ConsultationsCubit(consultationsRepository: sl())); sl.registerFactory(() => ConsultantCommentsCubit(diaryRepository: sl())); sl.registerFactory(() => CancelPlanCubit(cancelPlanRepository: sl())); diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index b14fb38c..bb260432 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/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(service: sl())); sl.registerLazySingleton(() => NotificationSettingsRepository(sl())); sl.registerLazySingleton(() => SbpRepository(sbpService: sl())); + sl.registerLazySingleton(() => PaymentRepository(chatService: sl())); sl.registerLazySingleton(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl())); sl.registerLazySingleton(() => RegistrationRepositoryV2(registrationService: sl())); diff --git a/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart new file mode 100644 index 00000000..ad0ae25e --- /dev/null +++ b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart @@ -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 { + final PaymentRepository paymentRepository; + final SbpRepository sbpRepository; + + ConsultationPaymentCubit({ + required this.paymentRepository, + required this.sbpRepository, + }) : super(ConsultationPaymentState()); + + /// Запускает оплату для [planId], сохраняет checkout_url и загружает банки СБП. + Future 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 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; + } + } +} diff --git a/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart new file mode 100644 index 00000000..31aa1c04 --- /dev/null +++ b/lib/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart @@ -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 banks, + String? checkoutUrl, + String? orderId, + String? errorMessage, + }) = _ConsultationPaymentState; +} diff --git a/lib/features/payment/repositories/payment_repository.dart b/lib/features/payment/repositories/payment_repository.dart new file mode 100644 index 00000000..1d590e48 --- /dev/null +++ b/lib/features/payment/repositories/payment_repository.dart @@ -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 startPaymentProcess({required int planId}) async { + return chatService.getPaymentLink( + id: planId, + body: const {'payment_provider': 'alfa-bank'}, + ); + } +} diff --git a/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart b/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart new file mode 100644 index 00000000..68586ec9 --- /dev/null +++ b/test/features/payment/presentation/cubit/consultation_payment_cubit_test.dart @@ -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(), + MockSpec(), +]) +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()); + 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); + }); + }); +} diff --git a/test/features/payment/repositories/payment_repository_test.dart b/test/features/payment/repositories/payment_repository_test.dart new file mode 100644 index 00000000..28d3d2f6 --- /dev/null +++ b/test/features/payment/repositories/payment_repository_test.dart @@ -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()]) +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.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()); + 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()), + ); + }); + }); +} From 0ca2d1a1cc829dfa2a5eb00603503c86d93b4a22 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 14 Jul 2026 13:09:18 +0300 Subject: [PATCH 11/16] feat(sbp_payments): added banks deep link handling to chat screen --- lib/core/app_router.dart | 25 +++++- lib/core/routing/other_routes.dart | 18 +++- .../bloc/chat_room_cubit/chat_room_cubit.dart | 37 ++++++-- .../chat/presentation/pages/chat_screen.dart | 38 ++++++-- pubspec.yaml | 2 +- test/core/app_router_test.dart | 45 ++++++++++ .../bloc/chat_room_cubit_test.dart | 90 ++++++++++++++++++- .../pages/chat_screen_loaded_smoke_test.dart | 3 +- .../pages/chat_screen_loading_smoke_test.dart | 3 +- .../order_id_to_chat_uuid_response.json | 2 +- 10 files changed, 234 insertions(+), 29 deletions(-) diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 62a840e0..391a8650 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -123,7 +123,22 @@ class AppRouter { return '${InfoByOrderScreen.path}?planId=$consultationId'; } - static String? _resolveDeepLinkUri(Uri uri) { + /// Deep link после оплаты Alfa: `finance/alfa/return/{orderId}` → чат с query `orderId`. + static String? _resolveFinanceAlfaReturnDeepLink(List effectiveSegments) { + if (effectiveSegments.length != 4 || + effectiveSegments[1] != 'alfa' || + effectiveSegments[2] != 'return') { + return null; + } + final orderId = effectiveSegments[3]; + if (orderId.isEmpty) return null; + developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter'); + print('Deep link: finance/alfa/return/$orderId'); + return '${RouteConstants.chatScreen}?orderId=$orderId'; + } + + /// Разбирает deep link URI в внутренний route target (или null, если host не наш). + static String? resolveDeepLinkTarget(Uri uri) { final host = uri.host.toLowerCase(); final segments = uri.pathSegments; @@ -189,6 +204,10 @@ class AppRouter { final target = _resolveConsultationsDeepLink(effectiveSegments); if (target != null) return target; break; + case 'finance': + final target = _resolveFinanceAlfaReturnDeepLink(effectiveSegments); + if (target != null) return target; + break; } developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter'); @@ -196,7 +215,7 @@ class AppRouter { } static String? _handleRedirect(BuildContext context, GoRouterState state) { - return _resolveDeepLinkUri(state.uri); + return resolveDeepLinkTarget(state.uri); } static void navigateByDeepLinkUrl(String url) { @@ -230,7 +249,7 @@ class AppRouter { ); } - final target = _resolveDeepLinkUri(uri); + final target = resolveDeepLinkTarget(uri); FlutterNativeSplash.remove(); if (target != null) { appRouter.go(target); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index b321a15c..91b6fab3 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -211,13 +211,23 @@ class OtherRoutes { name: 'chatScreen', builder: (context, state) { final uuidFromQuery = state.uri.queryParameters['uuid']; - if (uuidFromQuery != null) { - return ChatScreen(uuid: uuidFromQuery, sendInitialMessage: false); + final orderIdFromQuery = state.uri.queryParameters['orderId']; + if (uuidFromQuery != null || orderIdFromQuery != null) { + return ChatScreen( + uuid: uuidFromQuery, + orderId: orderIdFromQuery, + sendInitialMessage: false, + ); } final data = state.extra as Map; - final uuid = data['uuid'] as String; + final uuid = data['uuid'] as String?; + final orderId = data['orderId'] as String?; final sendInitialMessage = data['sendInitialMessage'] as bool?; - return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage); + return ChatScreen( + uuid: uuid, + orderId: orderId, + sendInitialMessage: sendInitialMessage, + ); }, ), GoRoute( diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index bb4a8ebd..2161393d 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -91,17 +91,38 @@ class ChatRoomCubit extends Cubit { } } - Future initChat(String uuid, {bool sendInitialMessage = false}) async { + /// Инициализирует чат по [uuid] либо по [orderId] (resolve → conversation_uuid). + /// + /// При наличии [orderId] сначала вызывается + /// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`. + /// + /// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve). + Future initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async { try { emit(const ChatRoomState.loading()); - final chatRoom = await chatRepository.getChatRoom(uuid); - ChosenPlanModel? chosenPlan; - if (chatRoom.chosenPlanId != null) { - chosenPlan = await chatRepository.getChosenPlan(id: chatRoom.chosenPlanId!); + + String? resolvedUuid = uuid; + int? resolvedChosenPlanId; + + if (orderId != null && orderId.isNotEmpty) { + final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId); + resolvedUuid = resolved.conversationUuid; + resolvedChosenPlanId = resolved.chosenPlanId; } - final chatMessages = await chatRepository.getChatMessages(uuid, currentPage); + + if (resolvedUuid == null || resolvedUuid.isEmpty) { + throw ArgumentError('initChat requires uuid or orderId'); + } + + final chatRoom = await chatRepository.getChatRoom(resolvedUuid); + ChosenPlanModel? chosenPlan; + final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId; + if (planId != null) { + chosenPlan = await chatRepository.getChosenPlan(id: planId); + } + final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage); lastPage = chatMessages.meta.lastPage; - await _subscribeToChatChannel(uuid: uuid, chatId: chatRoom.id); + await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id); emit( ChatRoomState.loaded( chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan), @@ -115,6 +136,7 @@ class ChatRoomCubit extends Cubit { if (chosenPlan?.consultantId == chatRoom.users.sender.id) { await checkCards(); } + return resolvedUuid; } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -122,6 +144,7 @@ class ChatRoomCubit extends Cubit { messagesSubscription?.cancel(); wsManager.unsubscribeDirectChatChannel(); emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных')); + return null; } } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 4c7b96e6..a8415590 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -36,10 +36,11 @@ import 'package:intl/intl.dart'; import 'package:visibility_detector/visibility_detector.dart'; class ChatScreen extends StatefulWidget { - final String uuid; + final String? uuid; + final String? orderId; final bool? sendInitialMessage; - const ChatScreen({required this.uuid, super.key, required this.sendInitialMessage}); + const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage}); @override State createState() => _ChatScreenState(); @@ -55,6 +56,11 @@ class _ChatScreenState extends State { late AppLifecycleListener _listener; List pickedFiles = []; + /// Conversation uuid после успешного init (в т.ч. после resolve по orderId). + String? _effectiveUuid; + + String? get _conversationUuid => _effectiveUuid ?? widget.uuid; + void _onHide() { chatRoomCubit.cancelTimer(); } @@ -64,26 +70,42 @@ class _ChatScreenState extends State { chatRoomCubit.setTimer(); } + Future _initChatRoom() async { + final uuid = await chatRoomCubit.initChat( + _conversationUuid, + orderId: _effectiveUuid == null ? widget.orderId : null, + sendInitialMessage: widget.sendInitialMessage ?? false, + ); + print('uuid: $uuid'); + if (uuid != null && mounted) { + setState(() => _effectiveUuid = uuid); + } + } + @override void didUpdateWidget(ChatScreen oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.uuid != widget.uuid) { - chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false); + if (oldWidget.uuid != widget.uuid || oldWidget.orderId != widget.orderId) { + _effectiveUuid = widget.uuid; + _initChatRoom(); } } @override void initState() { super.initState(); + _effectiveUuid = widget.uuid; scrollController.addListener(_onScroll); - chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false); + _initChatRoom(); chatRoomCubit.setTimer(); _listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume); } void _onScroll() { + final uuid = _conversationUuid; + if (uuid == null) return; if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) { - chatRoomCubit.loadNextPage(widget.uuid); + chatRoomCubit.loadNextPage(uuid); } } @@ -117,7 +139,7 @@ class _ChatScreenState extends State { }, ), ), - body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)), + body: DataErrorWidget(onReload: _initChatRoom), ); } else { return Scaffold( @@ -242,7 +264,7 @@ class _ChatScreenState extends State { GoRouter.of(context).pushNamed( VideoCallScreen.name, extra: { - 'conversationUuid': widget.uuid, + 'conversationUuid': _conversationUuid, 'counterpartName': state.chatRoom.users.receiver.name, 'localParticipantName': state.chatRoom.users.sender.name, }, diff --git a/pubspec.yaml b/pubspec.yaml index 509986fd..863f6d82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,7 +85,7 @@ dependencies: intl_utils: ^2.8.13 sentry_dio: ^9.10.0 livekit_client: ^2.6.1 - sbp: ^0.1.1 + sbp: 0.1.0 dev_dependencies: build_runner: ^2.10.4 diff --git a/test/core/app_router_test.dart b/test/core/app_router_test.dart index 19c61dab..648c5130 100644 --- a/test/core/app_router_test.dart +++ b/test/core/app_router_test.dart @@ -1,4 +1,5 @@ import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -6,6 +7,50 @@ import 'package:go_router/go_router.dart'; void main() { group('AppRouter', () { + group('resolveDeepLinkTarget - finance/alfa/return', () { + const orderId = 'alfa_c29_k15138_t1712345678_r1234'; + + test('finance/alfa/return/{orderId} → chat-screen?orderId=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/finance/alfa/return/$orderId'), + ); + + expect(target, '${RouteConstants.chatScreen}?orderId=$orderId'); + }); + + test('ru/finance/alfa/return/{orderId} → chat-screen?orderId= (metaSegments)', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/ru/finance/alfa/return/$orderId'), + ); + + expect(target, '${RouteConstants.chatScreen}?orderId=$orderId'); + }); + + test('неполный path /finance/alfa → feed', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/finance/alfa'), + ); + + expect(target, RouteConstants.feedScreen); + }); + + test('неполный path /finance → feed', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/finance'), + ); + + expect(target, RouteConstants.feedScreen); + }); + + test('регресс: chat/{uuid} → chat-screen?uuid=', () { + final target = AppRouter.resolveDeepLinkTarget( + Uri.parse('https://eatvio.ru/ru/chat/uuid-1'), + ); + + expect(target, '${RouteConstants.chatScreen}?uuid=uuid-1'); + }); + }); + test('navigatorKey инициализирован', () { expect(AppRouter.navigatorKey, isNotNull); expect(AppRouter.navigatorKey, isA>()); diff --git a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart index b59f16d8..d1f14bba 100644 --- a/test/features/chat/presentation/bloc/chat_room_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chat_room_cubit_test.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_ import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/my_finance/models/card_model.dart'; +import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; @@ -102,6 +103,89 @@ void main() { // Let's check that at least the repository methods were called verify(mockChatRepository.getChatRoom(uuid)).called(1); verify(mockChatRepository.getChatMessages(uuid, 1)).called(1); + verifyNever( + mockChatRepository.resolveOrderIdToConversation(orderId: anyNamed('orderId')), + ); + }); + + test('initChat - с orderId резолвит conversation и инициализирует чат', () async { + const orderId = 'alfa_c29_k15138_t1712345678_r1234'; + const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872'; + const chosenPlanId = 12; + final resolved = OrderIdToConversationModel( + chosenPlanId: chosenPlanId, + conversationUuid: conversationUuid, + ); + final chatRoomWithPlan = testChatRoom.copyWith(chosenPlanId: chosenPlanId); + const testPlan = PlanModel( + id: 1, + slug: 'test-plan', + name: 'Test Plan', + image: 'https://example.com/image.jpg', + duration: 7, + state: 'active', + type: PlanType(fit: true, slim: false, behavior: false, wellness: false), + details: 'Test details', + packetsCount: 3, + author: Author( + authorSlug: 'author', + authorName: 'Author Name', + authorProfileIcon: 'https://example.com/icon.jpg', + authorIsMe: false, + ), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + isFavorite: false, + minPriceOfOptions: 1000, + maxPriceOfOptions: 2000, + wasLikedByMe: false, + ); + final chosenPlan = ChosenPlanModel( + id: chosenPlanId, + optionType: 'options1', + startDate: DateTime.now(), + chosenPlanState: ChosenPlanStateEnum.statePendingPayment, + chatId: 'chat-1', + consultantId: 2, + plan: testPlan, + ); + + when( + mockChatRepository.resolveOrderIdToConversation(orderId: orderId), + ).thenAnswer((_) async => resolved); + when(mockChatRepository.getChatRoom(conversationUuid)).thenAnswer((_) async => chatRoomWithPlan); + when(mockChatRepository.getChatMessages(conversationUuid, 1)).thenAnswer((_) async => testChatMessages); + when(mockChatRepository.getChosenPlan(id: chosenPlanId)).thenAnswer((_) async => chosenPlan); + when(mockWsManager.client).thenReturn(null); + when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); + when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); + when(mockWsManager.directChatStream).thenReturn(null); + when(mockWsManager.clientTypingStream).thenReturn(null); + + await chatRoomCubit.initChat(null, orderId: orderId); + await Future.delayed(const Duration(milliseconds: 200)); + + verify(mockChatRepository.resolveOrderIdToConversation(orderId: orderId)).called(1); + verify(mockChatRepository.getChatRoom(conversationUuid)).called(1); + verify(mockChatRepository.getChosenPlan(id: chosenPlanId)).called(1); + verify(mockChatRepository.getChatMessages(conversationUuid, 1)).called(1); + expect(chatRoomCubit.state, isA()); + final loaded = chatRoomCubit.state as ChatRoomStateLoaded; + expect(loaded.chatRoom.chosenPlan?.id, chosenPlanId); + }); + + test('initChat - при ошибке resolve не вызывает getChatRoom', () async { + const orderId = 'alfa_c29_k15138_t1712345678_r1234'; + when( + mockChatRepository.resolveOrderIdToConversation(orderId: orderId), + ).thenThrow(Exception('Resolve failed')); + + await chatRoomCubit.initChat(null, orderId: orderId); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(chatRoomCubit.state, isA()); + verifyNever(mockChatRepository.getChatRoom(any)); }); test('initChat - обрабатывает ошибку', () async { @@ -678,7 +762,7 @@ void main() { when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'mock')).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl)); + when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl)); when(mockWsManager.client).thenReturn(null); when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); @@ -691,7 +775,7 @@ void main() { final result = await chatRoomCubit.getPaymentLink(); expect(result, paymentUrl); - verify(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'mock')).called(1); + verify(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).called(1); }); test('getPaymentLink - обрабатывает ошибку', () async { @@ -730,7 +814,7 @@ void main() { when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); - when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'mock')).thenThrow(Exception('Payment error')); + when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenThrow(Exception('Payment error')); when(mockWsManager.client).thenReturn(null); when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); diff --git a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart index c36e6893..6d705f9b 100644 --- a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart +++ b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart @@ -147,7 +147,8 @@ void main() { when(chatRoomCubit.state).thenReturn(state); when(chatRoomCubit.stream).thenAnswer((_) => const Stream.empty()); when(chatRoomCubit.close()).thenAnswer((_) async => Future.value()); - when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'))).thenAnswer((_) async => Future.value()); + when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId'))) + .thenAnswer((_) async => 'uuid'); when(chatRoomCubit.setTimer()).thenReturn(null); diff --git a/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart b/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart index 3a59cd8e..cf6d27cf 100644 --- a/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart +++ b/test/features/chat/presentation/pages/chat_screen_loading_smoke_test.dart @@ -33,7 +33,8 @@ void main() { when(cubit.state).thenReturn(const ChatRoomState.loading()); when(cubit.stream).thenAnswer((_) => const Stream.empty()); when(cubit.close()).thenAnswer((_) async => Future.value()); - when(cubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'))).thenAnswer((_) async => Future.value()); + when(cubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId'))) + .thenAnswer((_) async => 'uuid'); when(cubit.setTimer()).thenReturn(null); sl.registerFactory(() => cubit); diff --git a/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json b/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json index 0e74edc1..d74b93fd 100644 --- a/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json +++ b/test/fixtures/chat/payments/order_id_to_chat_uuid_response.json @@ -2,4 +2,4 @@ { "chosen_plan_id": 12, "conversation_uuid": "fbd81429-8019-499a-80e8-e3933cc4d872" -} \ No newline at end of file +} From e94681266ae6ebe4bcb710bca294238d2b355651 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 14 Jul 2026 13:43:46 +0300 Subject: [PATCH 12/16] feat(sbp_payments): added new layoute for sbp banks and rederict to chosen bank, and added tests for layoute --- lib/core/app_router.dart | 1 - .../chat/presentation/pages/chat_screen.dart | 1 - .../widgets/notification_banner.dart | 29 +-- .../widgets/sbp_banks_bottom_sheet.dart | 224 ++++++++++++++++++ lib/generated/intl/messages_en.dart | 9 + lib/generated/intl/messages_ru.dart | 9 + lib/generated/l10n.dart | 30 +++ lib/l10n/intl_en.arb | 3 + lib/l10n/intl_ru.arb | 5 +- .../widgets/sbp_banks_bottom_sheet_test.dart | 187 +++++++++++++++ 10 files changed, 471 insertions(+), 27 deletions(-) create mode 100644 lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart create mode 100644 test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 391a8650..9b6f9610 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -133,7 +133,6 @@ class AppRouter { final orderId = effectiveSegments[3]; if (orderId.isEmpty) return null; developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter'); - print('Deep link: finance/alfa/return/$orderId'); return '${RouteConstants.chatScreen}?orderId=$orderId'; } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index a8415590..8a2d42e6 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -76,7 +76,6 @@ class _ChatScreenState extends State { orderId: _effectiveUuid == null ? widget.orderId : null, sendInitialMessage: widget.sendInitialMessage ?? false, ); - print('uuid: $uuid'); if (uuid != null && mounted) { setState(() => _effectiveUuid = uuid); } diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart index c2a8069b..59650d6c 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -2,15 +2,14 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; -import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; -import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart'; import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -29,7 +28,6 @@ class NotificationBanner extends StatefulWidget { } class _NotificationBannerState extends State { - bool isLoading = false; @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); @@ -54,13 +52,7 @@ class _NotificationBannerState extends State { padding: EdgeInsets.only(top: 12, right: 8, left: 12), child: _StarIcon(), ), - if (isLoading) - const Padding( - padding: EdgeInsets.only(left: 40, top: 24), - child: Loader(), - ) - else - Padding( + Padding( padding: const EdgeInsets.only(top: 16), child: Column( mainAxisSize: MainAxisSize.min, @@ -111,21 +103,10 @@ class _NotificationBannerState extends State { ), const SizedBox(height: 12), GestureDetector( - onTap: () async { + onTap: () { final planId = (context.read().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; - setState(() { - isLoading = true; - }); - final paymentLink = await context.read().getPaymentLink(); - setState(() { - isLoading = false; - }); - if (paymentLink != null && planId != null) { - if (!context.mounted) return; - GoRouter.of(context).pushReplacement( - PaymentScreen.path, - extra: {'url': paymentLink, 'planId': planId}, - ); + if (planId != null) { + showSbpBanksBottomSheet(context, planId: planId); } }, child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), diff --git a/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart b/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart new file mode 100644 index 00000000..5b0f95e7 --- /dev/null +++ b/lib/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart @@ -0,0 +1,224 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; +import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; +import 'package:eatvio_app/core/widgets/loader.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/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; + +/// Показывает bottom sheet выбора банка СБП для оплаты консультации. +Future showSbpBanksBottomSheet(BuildContext context, {required int planId}) { + final height = MediaQuery.sizeOf(context).height * 0.55; + return showBottomSheetWithBlur( + context: context, + height: height, + child: SbpBanksBottomSheet(planId: planId), + ); +} + +/// Bottom sheet со списком установленных банков СБП. +/// +/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId] +/// и показывает [Loader], пока загружаются checkout_url и список банков. +class SbpBanksBottomSheet extends StatefulWidget { + const SbpBanksBottomSheet({super.key, required this.planId, this.cubit}); + + final int planId; + final ConsultationPaymentCubit? cubit; + + @override + State createState() => _SbpBanksBottomSheetState(); +} + +class _SbpBanksBottomSheetState extends State { + late final ConsultationPaymentCubit _cubit; + late final bool _ownsCubit; + + @override + void initState() { + super.initState(); + _ownsCubit = widget.cubit == null; + _cubit = widget.cubit ?? sl(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _cubit.startPayment(planId: widget.planId); + }); + } + + @override + void dispose() { + if (_ownsCubit) { + _cubit.close(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return BlocProvider.value( + value: _cubit, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + const BottomSheetHandlerBar(), + const SizedBox(height: 20), + Text( + s.choose_bank_for_payment, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Loader(); + } + if (state.errorMessage != null) { + return _ErrorContent( + message: s.sbp_payment_error, + onOk: () { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } + }, + ); + } + if (state.banks.isEmpty) { + return Center( + child: Text( + s.no_installed_banks, + style: TextStyles.textMMedium.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + ); + } + return ListView.separated( + itemCount: state.banks.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final bank = state.banks[index]; + return _BankTile(bank: bank, onTap: () => _onBankTap(bank)); + }, + ); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Future _onBankTap(C2bmemberModel bank) async { + final opened = await _cubit.openBank(bank: bank); + if (opened && mounted) { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } + } + } +} + +class _BankTile extends StatelessWidget { + const _BankTile({required this.bank, required this.onTap}); + + final C2bmemberModel bank; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final name = bank.localName ?? bank.bankName; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), + child: Row( + children: [ + _BankIcon(bank: bank), + const SizedBox(width: 12), + Expanded( + child: Text(name, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)), + ), + ], + ), + ), + ); + } +} + +class _BankIcon extends StatelessWidget { + const _BankIcon({required this.bank}); + + final C2bmemberModel bank; + + @override + Widget build(BuildContext context) { + if (bank.icon.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: bank.logoURL, + width: 40, + height: 40, + placeholder: (context, url) => _placeholder(), + errorWidget: (context, url, error) => _placeholder(), + ), + ); + } + return _placeholder(); + } + + Widget _placeholder() { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: appTheme.blueA400.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)), + child: Icon(Icons.account_balance, color: appTheme.blueA400, size: 22), + ); + } +} + +class _ErrorContent extends StatelessWidget { + const _ErrorContent({required this.message, required this.onOk}); + + final String message; + final VoidCallback onOk; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + message, + style: TextStyles.textMMedium.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + CustomOutlinedButton( + text: s.ok, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), + onPressed: onOk, + ), + ], + ); + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 096562d6..d79fe75d 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -173,6 +173,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "chest": MessageLookupByLibrary.simpleMessage("Грудь"), "chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"), + "choose_bank_for_payment": MessageLookupByLibrary.simpleMessage( + "Выберите банк", + ), "choose_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"), "choose_diet_duration": MessageLookupByLibrary.simpleMessage( "Выберите срок диеты", @@ -588,6 +591,9 @@ class MessageLookup extends MessageLookupByLibrary { "no_default_cards": MessageLookupByLibrary.simpleMessage( "У вас нет привязанных карт. Что бы начать работу, привяжите карту", ), + "no_installed_banks": MessageLookupByLibrary.simpleMessage( + "Нет установленных банков", + ), "not_liked_groceries": MessageLookupByLibrary.simpleMessage( "Не любимые продукты", ), @@ -737,6 +743,9 @@ class MessageLookup extends MessageLookupByLibrary { "reply": MessageLookupByLibrary.simpleMessage("Ответить"), "resend": MessageLookupByLibrary.simpleMessage("Отправить повторно"), "save": MessageLookupByLibrary.simpleMessage("Сохранить"), + "sbp_payment_error": MessageLookupByLibrary.simpleMessage( + "Не удалось начать оплату. Попробуйте позже", + ), "send": MessageLookupByLibrary.simpleMessage("Отправить"), "send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 86adc84c..b9c4300d 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -62,6 +62,9 @@ class MessageLookup extends MessageLookupByLibrary { "chat": MessageLookupByLibrary.simpleMessage("Чат"), "chest": MessageLookupByLibrary.simpleMessage("Грудь"), "chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"), + "choose_bank_for_payment": MessageLookupByLibrary.simpleMessage( + "Выберите банк", + ), "choose_diet_duration": MessageLookupByLibrary.simpleMessage( "Выберите срок диеты", ), @@ -243,6 +246,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "no": MessageLookupByLibrary.simpleMessage("Нет"), "no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"), + "no_installed_banks": MessageLookupByLibrary.simpleMessage( + "Нет установленных банков", + ), "notifications": MessageLookupByLibrary.simpleMessage("Уведомления"), "one_option": MessageLookupByLibrary.simpleMessage("1 опция"), "option": MessageLookupByLibrary.simpleMessage("Опция"), @@ -314,6 +320,9 @@ class MessageLookup extends MessageLookupByLibrary { "Снять с публикации", ), "save": MessageLookupByLibrary.simpleMessage("Сохранить"), + "sbp_payment_error": MessageLookupByLibrary.simpleMessage( + "Не удалось начать оплату. Попробуйте позже", + ), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"), "settings": MessageLookupByLibrary.simpleMessage("Настройки"), "share": MessageLookupByLibrary.simpleMessage("Поделиться"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 3bca8ece..41b13d8d 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2649,6 +2649,36 @@ class S { return Intl.message('Оплатить', name: 'pay', desc: '', args: []); } + /// `Выберите банк` + String get choose_bank_for_payment { + return Intl.message( + 'Выберите банк', + name: 'choose_bank_for_payment', + desc: '', + args: [], + ); + } + + /// `Нет установленных банков` + String get no_installed_banks { + return Intl.message( + 'Нет установленных банков', + name: 'no_installed_banks', + desc: '', + args: [], + ); + } + + /// `Не удалось начать оплату. Попробуйте позже` + String get sbp_payment_error { + return Intl.message( + 'Не удалось начать оплату. Попробуйте позже', + name: 'sbp_payment_error', + desc: '', + args: [], + ); + } + /// `Подробнее о заказе` String get more_about_order { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 27c662dd..855b25e8 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -326,6 +326,9 @@ "date_has_ben_set": "Дата старта начала консультации установлена", "tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации", "pay": "Оплатить", + "choose_bank_for_payment": "Выберите банк", + "no_installed_banks": "Нет установленных банков", + "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже", "more_about_order": "Подробнее о заказе", "cancel_order": "Отменить заказ", "refuse_order":"Отказаться от заказа", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 687f142c..b160ec83 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -233,5 +233,8 @@ "video_call_permission_camera_title": "Доступ к камере", "video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.", "video_call_permission_microphone_title": "Доступ к микрофону", - "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства." + "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", + "choose_bank_for_payment": "Выберите банк", + "no_installed_banks": "Нет установленных банков", + "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже" } diff --git a/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart b/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart new file mode 100644 index 00000000..773a1d74 --- /dev/null +++ b/test/features/payment/presentation/widgets/sbp_banks_bottom_sheet_test.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +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/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/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:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sbp/models/c2bmembers_model.dart'; + +import '../../../../fixtures/fixture_reader.dart'; +import 'sbp_banks_bottom_sheet_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SbpBanksBottomSheet', () { + late MockPaymentRepository mockPaymentRepository; + late MockSbpRepository mockSbpRepository; + late ConsultationPaymentCubit cubit; + late PaymentModel alfaPayment; + + final installedBank = C2bmemberModel.fromJson(const { + 'bankName': 'Сбербанк', + 'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png', + 'schema': 'bank100000000111', + 'package_name': 'ru.sberbankmobile', + 'icon': '', + }); + + 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(); + }); + + Widget createTestWidget({required int planId}) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold( + body: SbpBanksBottomSheet( + planId: planId, + cubit: cubit, + ), + ), + ); + } + + testWidgets('показывает Loader пока идёт startPayment', (tester) async { + const planId = 12; + final completer = Completer(); + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) => completer.future); + + await tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pump(); + + expect(find.byType(Loader), findsOneWidget); + expect(find.text('Сбербанк'), findsNothing); + + completer.complete(alfaPayment); + when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]); + await tester.pumpAndSettle(); + }); + + testWidgets('вызывает startPaymentProcess с переданным planId', (tester) async { + const planId = 12; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]); + + await tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pumpAndSettle(); + + verify(mockPaymentRepository.startPaymentProcess(planId: planId)).called(1); + }); + + testWidgets('после успеха показывает список банков и скрывает Loader', (tester) async { + const planId = 12; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]); + + await tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pumpAndSettle(); + + expect(find.byType(Loader), findsNothing); + expect(find.text('Выберите банк'), findsOneWidget); + expect(find.text('Сбербанк'), findsOneWidget); + }); + + testWidgets('tap по банку вызывает openBank с checkout_url', (tester) async { + const planId = 12; + + 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 tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Сбербанк')); + await tester.pumpAndSettle(); + + verify( + mockSbpRepository.openBank( + url: alfaPayment.paymentUrl, + bank: installedBank, + ), + ).called(1); + }); + + testWidgets('при ошибке API показывает сообщение об ошибке', (tester) async { + const planId = 12; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenThrow(Exception('Payment start failed')); + + await tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pumpAndSettle(); + + expect(find.byType(Loader), findsNothing); + expect(find.text('Сбербанк'), findsNothing); + expect( + find.text('Не удалось начать оплату. Попробуйте позже'), + findsOneWidget, + ); + }); + + testWidgets('при пустом списке банков показывает empty-сообщение', (tester) async { + const planId = 12; + + when( + mockPaymentRepository.startPaymentProcess(planId: planId), + ).thenAnswer((_) async => alfaPayment); + when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => []); + + await tester.pumpWidget(createTestWidget(planId: planId)); + await tester.pumpAndSettle(); + + expect(find.byType(Loader), findsNothing); + expect(find.text('Нет установленных банков'), findsOneWidget); + }); + }); +} From b064c7016e299fad474e0cd0653dcf4505bc0ca2 Mon Sep 17 00:00:00 2001 From: Anatoliy Date: Tue, 14 Jul 2026 15:50:44 +0300 Subject: [PATCH 13/16] feat(payment, chat): promo code mocked BottomSheet added --- .../widgets/notification_banner.dart | 4 +- .../payment/presentation/payment_flow.dart | 49 +++ .../widgets/promo_code_gate_bottom_sheet.dart | 97 ++++++ .../widgets/promo_code_modal.dart | 281 ++++++++++++++++++ lib/generated/intl/messages_en.dart | 15 + lib/generated/intl/messages_ru.dart | 15 + lib/generated/l10n.dart | 55 ++++ lib/l10n/intl_en.arb | 9 +- lib/l10n/intl_ru.arb | 9 +- .../consultation_payment_flow_test.dart | 131 ++++++++ .../promo_code_gate_bottom_sheet_test.dart | 65 ++++ .../widgets/promo_code_modal_test.dart | 101 +++++++ 12 files changed, 827 insertions(+), 4 deletions(-) create mode 100644 lib/features/payment/presentation/payment_flow.dart create mode 100644 lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart create mode 100644 lib/features/payment/presentation/widgets/promo_code_modal.dart create mode 100644 test/features/payment/consultation_payment_flow_test.dart create mode 100644 test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart create mode 100644 test/features/payment/presentation/widgets/promo_code_modal_test.dart diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart index 59650d6c..11db4288 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -9,7 +9,7 @@ import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bott import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart'; import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; -import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; +import 'package:eatvio_app/features/payment/presentation/payment_flow.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -106,7 +106,7 @@ class _NotificationBannerState extends State { onTap: () { final planId = (context.read().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; if (planId != null) { - showSbpBanksBottomSheet(context, planId: planId); + showConsultationPaymentFlow(context, planId: planId); } }, child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), diff --git a/lib/features/payment/presentation/payment_flow.dart b/lib/features/payment/presentation/payment_flow.dart new file mode 100644 index 00000000..03cfe5b5 --- /dev/null +++ b/lib/features/payment/presentation/payment_flow.dart @@ -0,0 +1,49 @@ +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +/// Оркестрирует флоу оплаты консультации: +/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks. +Future showConsultationPaymentFlow( + BuildContext context, { + required int planId, + Future Function(BuildContext)? showGate, + Future Function(BuildContext, {Future Function(String)? onValidate})? showForm, + Future Function(BuildContext, {required int planId})? showSbp, +}) async { + final gate = showGate ?? _showGate; + final form = showForm ?? _showForm; + final sbp = showSbp ?? _showSbp; + final s = S.of(context); + + final gateChoice = await gate(context); + if (gateChoice == null || !context.mounted) return; + + if (gateChoice) { + final formResult = await form( + context, + onValidate: (_) async => PromoValidation.failure(s.promo_invalid), + ); + if (formResult == null || !context.mounted) return; + } + + if (!context.mounted) return; + await sbp(context, planId: planId); +} + +Future _showGate(BuildContext context) { + return showPromoCodeGateBottomSheet(context); +} + +Future _showForm( + BuildContext context, { + Future Function(String)? onValidate, +}) { + return ItvioPromoCodeModal.show(context, onValidate: onValidate); +} + +Future _showSbp(BuildContext context, {required int planId}) { + return showSbpBanksBottomSheet(context, planId: planId); +} diff --git a/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart b/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart new file mode 100644 index 00000000..b72a3f13 --- /dev/null +++ b/lib/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart @@ -0,0 +1,97 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +/// Показывает bottom sheet «У вас есть промокод?» с кнопками Да/Нет. +/// +/// Возвращает `true` если пользователь нажал «Да», `false` если «Нет», +/// `null` если закрыл шторку (бекдроп/крестик). +Future showPromoCodeGateBottomSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + barrierColor: appTheme.transparentBlack, + backgroundColor: Colors.transparent, + elevation: 0, + useSafeArea: true, + builder: (_) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: _PromoCodeGateSheet(), + ), + ); +} + +/// Виджет-гейт выбора промокода. +class PromoCodeGateBottomSheet extends StatelessWidget { + const PromoCodeGateBottomSheet({super.key, required this.onResult}); + + final ValueChanged onResult; + + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: BottomSheetHandlerBar()), + const SizedBox(height: 24), + Text( + s.have_promo_code, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + SizedBox( + height: 50, + child: FilledButton( + onPressed: () => onResult(true), + style: FilledButton.styleFrom( + backgroundColor: appTheme.blueA400, + foregroundColor: appTheme.whiteA700, + shape: const StadiumBorder(), + ), + child: Text( + s.yes, + style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 50, + child: OutlinedButton( + onPressed: () => onResult(false), + style: OutlinedButton.styleFrom( + foregroundColor: appTheme.blueA400, + side: BorderSide(color: appTheme.blueA400), + shape: const StadiumBorder(), + ), + child: Text( + s.no, + style: TextStyles.textMSemibold.copyWith(color: appTheme.blueA400), + ), + ), + ), + ], + ), + ); + } +} + +/// Внутренняя реализация модалки — обёртка над [PromoCodeGateBottomSheet]. +class _PromoCodeGateSheet extends StatelessWidget { + @override + Widget build(BuildContext context) { + return PromoCodeGateBottomSheet( + onResult: (result) => Navigator.of(context).pop(result), + ); + } +} diff --git a/lib/features/payment/presentation/widgets/promo_code_modal.dart b/lib/features/payment/presentation/widgets/promo_code_modal.dart new file mode 100644 index 00000000..0d4a9942 --- /dev/null +++ b/lib/features/payment/presentation/widgets/promo_code_modal.dart @@ -0,0 +1,281 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +/// Результат работы модалки промокода. +class PromoCodeResult { + const PromoCodeResult({this.code}); + + /// Применённый промокод или null, если пользователь продолжил без него. + final String? code; + + bool get hasPromoCode => code != null; +} + +/// Ответ проверки промокода. +class PromoValidation { + const PromoValidation.success() : error = null; + const PromoValidation.failure(this.error); + + final String? error; + + bool get isValid => error == null; +} + +/// Модальное окно ввода промокода. +class ItvioPromoCodeModal extends StatefulWidget { + const ItvioPromoCodeModal({super.key, this.onValidate}); + + final Future Function(String code)? onValidate; + + static Future show( + BuildContext context, { + Future Function(String code)? onValidate, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => ItvioPromoCodeModal(onValidate: onValidate), + ); + } + + @override + State createState() => _ItvioPromoCodeModalState(); +} + +enum _ModalState { input, loading } + +class _ItvioPromoCodeModalState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + + _ModalState _state = _ModalState.input; + String? _errorText; + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _apply() async { + final code = _controller.text.trim().toUpperCase(); + + if (code.isEmpty) { + setState(() => _errorText = S.of(context).enter_promo_code); + return; + } + + setState(() { + _state = _ModalState.loading; + _errorText = null; + }); + + PromoValidation validation; + try { + validation = widget.onValidate != null + ? await widget.onValidate!(code) + : const PromoValidation.success(); + } catch (_) { + validation = const PromoValidation.failure( + 'Не удалось проверить промокод. Попробуйте позже', + ); + } + + if (!mounted) return; + + if (!validation.isValid) { + setState(() { + _state = _ModalState.input; + _errorText = validation.error; + }); + _focusNode.requestFocus(); + return; + } + + Navigator.of(context).pop(PromoCodeResult(code: code)); + } + + void _onSkip() { + Navigator.of(context).pop(const PromoCodeResult()); + } + + bool get _isLoading => _state == _ModalState.loading; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final s = S.of(context); + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: BottomSheetHandlerBar()), + const SizedBox(height: 16), + _buildHeader(), + const SizedBox(height: 16), + const Text( + 'Промокод', + style: TextStyle( + fontSize: 13, + color: Color(0xFF6B7684), + ), + ), + const SizedBox(height: 8), + _buildField(), + const SizedBox(height: 8), + _buildHint(), + const SizedBox(height: 12), + _buildApplyButton(s), + const SizedBox(height: 6), + _buildSkipButton(s), + ], + ), + ), + ); + } + + Widget _buildHeader() { + final s = S.of(context); + return Text( + s.have_promo_code_form, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + ); + } + + Widget _buildField() { + final hasError = _errorText != null; + final s = S.of(context); + + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: appTheme.gray100, + borderRadius: BorderRadius.circular(12), + border: Border.all( + width: 2, + color: hasError + ? appTheme.red + : (_focusNode.hasFocus + ? appTheme.blueA400 + : Colors.transparent), + ), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: !_isLoading, + textCapitalization: TextCapitalization.characters, + onChanged: (_) { + if (_errorText != null) { + setState(() => _errorText = null); + } + }, + onSubmitted: (_) => _apply(), + decoration: InputDecoration( + hintText: s.enter_promo_code, + hintStyle: const TextStyle( + color: Color(0xFF9AA3AF), + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + style: const TextStyle( + fontSize: 15, + color: Color(0xFF24303F), + ), + ), + ), + ], + ), + ); + } + + Widget _buildHint() { + final String text; + final Color color; + + if (_errorText != null) { + text = _errorText!; + color = appTheme.red; + } else { + text = ''; + color = Colors.transparent; + } + + return SizedBox( + height: 19, + child: Text( + text, + style: TextStyle(fontSize: 13, color: color), + overflow: TextOverflow.ellipsis, + ), + ); + } + + Widget _buildApplyButton(S s) { + return SizedBox( + height: 50, + child: FilledButton( + onPressed: _isLoading ? null : _apply, + style: FilledButton.styleFrom( + backgroundColor: appTheme.blueA400, + foregroundColor: appTheme.whiteA700, + disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Text(s.apply), + ), + ); + } + + Widget _buildSkipButton(S s) { + return SizedBox( + height: 46, + child: TextButton( + onPressed: _isLoading ? null : _onSkip, + style: TextButton.styleFrom( + foregroundColor: appTheme.blueA400, + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 14), + ), + child: Text(s.skip), + ), + ); + } +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index d79fe75d..58eb63e6 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -123,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary { "another_form_of_minute": MessageLookupByLibrary.simpleMessage("минут"), "another_form_of_stage": MessageLookupByLibrary.simpleMessage("этапа"), "answer": MessageLookupByLibrary.simpleMessage("Ответить"), + "apply": MessageLookupByLibrary.simpleMessage("Apply"), "archived": MessageLookupByLibrary.simpleMessage("В архиве"), "atLeast3Symbols": MessageLookupByLibrary.simpleMessage( "Минимум 3 символа", @@ -381,6 +382,9 @@ class MessageLookup extends MessageLookupByLibrary { "enter_information": MessageLookupByLibrary.simpleMessage( "Введите информацию...", ), + "enter_promo_code": MessageLookupByLibrary.simpleMessage( + "Enter promo code", + ), "error_desc": MessageLookupByLibrary.simpleMessage( "Произошла ошибка. Мы уже работаем над её устранением. Попробуйте позже", ), @@ -466,6 +470,12 @@ class MessageLookup extends MessageLookupByLibrary { "hatred_description": MessageLookupByLibrary.simpleMessage( "Враждебные высказывания", ), + "have_promo_code": MessageLookupByLibrary.simpleMessage( + "Do you have a promo code?", + ), + "have_promo_code_form": MessageLookupByLibrary.simpleMessage( + "Have a promo code?", + ), "height": MessageLookupByLibrary.simpleMessage("Рост"), "height_error": MessageLookupByLibrary.simpleMessage("Введите рост"), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), @@ -705,6 +715,10 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), + "promo_invalid": MessageLookupByLibrary.simpleMessage( + "Promo code not found or expired", + ), "prostitution": MessageLookupByLibrary.simpleMessage("Проституция"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), @@ -953,6 +967,7 @@ class MessageLookup extends MessageLookupByLibrary { "wrong_credentials": MessageLookupByLibrary.simpleMessage( "Неверные данные пользователя", ), + "yes": MessageLookupByLibrary.simpleMessage("Yes"), "youCanComplain": MessageLookupByLibrary.simpleMessage( "Если вас что-то не устроило, вы можете подать жалобу", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index b9c4300d..884317a4 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -45,6 +45,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "another_form_of_day": MessageLookupByLibrary.simpleMessage("дня"), "answer": MessageLookupByLibrary.simpleMessage("Ответить"), + "apply": MessageLookupByLibrary.simpleMessage("Применить"), "archived": MessageLookupByLibrary.simpleMessage("В архиве"), "authentificate": MessageLookupByLibrary.simpleMessage("Войти"), "authorization": MessageLookupByLibrary.simpleMessage("Авторизация"), @@ -154,6 +155,9 @@ class MessageLookup extends MessageLookupByLibrary { "enter_information": MessageLookupByLibrary.simpleMessage( "Введите информацию...", ), + "enter_promo_code": MessageLookupByLibrary.simpleMessage( + "Введите промокод", + ), "exit": MessageLookupByLibrary.simpleMessage("Выйти"), "expand": MessageLookupByLibrary.simpleMessage("Показать полностью"), "fats": MessageLookupByLibrary.simpleMessage("Жиры"), @@ -184,6 +188,12 @@ class MessageLookup extends MessageLookupByLibrary { "gallery": MessageLookupByLibrary.simpleMessage("Галерея"), "gr": MessageLookupByLibrary.simpleMessage("гр"), "grams": MessageLookupByLibrary.simpleMessage("гр"), + "have_promo_code": MessageLookupByLibrary.simpleMessage( + "У вас есть промокод?", + ), + "have_promo_code_form": MessageLookupByLibrary.simpleMessage( + "Есть промокод?", + ), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), "hide": MessageLookupByLibrary.simpleMessage("Свернуть"), "hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"), @@ -312,6 +322,10 @@ class MessageLookup extends MessageLookupByLibrary { "Разница в ценах, это комиссия Итвио", ), "profile": MessageLookupByLibrary.simpleMessage("Профиль"), + "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), + "promo_invalid": MessageLookupByLibrary.simpleMessage( + "Промокод не найден или истёк", + ), "proteins": MessageLookupByLibrary.simpleMessage("Белки"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "publish": MessageLookupByLibrary.simpleMessage("Опубликовать"), @@ -413,6 +427,7 @@ class MessageLookup extends MessageLookupByLibrary { "wrong_credentials": MessageLookupByLibrary.simpleMessage( "Неверные данные пользователя", ), + "yes": MessageLookupByLibrary.simpleMessage("Да"), "you_will_get": MessageLookupByLibrary.simpleMessage("Вы получите"), "your_blood_pressure": MessageLookupByLibrary.simpleMessage( "Ваше артериальное давление?", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 41b13d8d..cf3507df 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4178,6 +4178,61 @@ class S { args: [], ); } + + /// `Yes` + String get yes { + return Intl.message('Yes', name: 'yes', desc: '', args: []); + } + + /// `Apply` + String get apply { + return Intl.message('Apply', name: 'apply', desc: '', args: []); + } + + /// `Do you have a promo code?` + String get have_promo_code { + return Intl.message( + 'Do you have a promo code?', + name: 'have_promo_code', + desc: '', + args: [], + ); + } + + /// `Have a promo code?` + String get have_promo_code_form { + return Intl.message( + 'Have a promo code?', + name: 'have_promo_code_form', + desc: '', + args: [], + ); + } + + /// `Promo code` + String get promo_code { + return Intl.message('Promo code', name: 'promo_code', desc: '', args: []); + } + + /// `Enter promo code` + String get enter_promo_code { + return Intl.message( + 'Enter promo code', + name: 'enter_promo_code', + desc: '', + args: [], + ); + } + + /// `Promo code not found or expired` + String get promo_invalid { + return Intl.message( + 'Promo code not found or expired', + name: 'promo_invalid', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 855b25e8..9bb90bec 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -491,6 +491,13 @@ "video_call_permission_camera_title": "Camera access", "video_call_permission_camera_description": "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", "video_call_permission_microphone_title": "Microphone access", - "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings." + "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.", + "yes": "Yes", + "apply": "Apply", + "have_promo_code": "Do you have a promo code?", + "have_promo_code_form": "Have a promo code?", + "promo_code": "Promo code", + "enter_promo_code": "Enter promo code", + "promo_invalid": "Promo code not found or expired" } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index b160ec83..bae97144 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -236,5 +236,12 @@ "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", "choose_bank_for_payment": "Выберите банк", "no_installed_banks": "Нет установленных банков", - "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже" + "sbp_payment_error": "Не удалось начать оплату. Попробуйте позже", + "yes": "Да", + "apply": "Применить", + "have_promo_code": "У вас есть промокод?", + "have_promo_code_form": "Есть промокод?", + "promo_code": "Промокод", + "enter_promo_code": "Введите промокод", + "promo_invalid": "Промокод не найден или истёк" } diff --git a/test/features/payment/consultation_payment_flow_test.dart b/test/features/payment/consultation_payment_flow_test.dart new file mode 100644 index 00000000..667c84ed --- /dev/null +++ b/test/features/payment/consultation_payment_flow_test.dart @@ -0,0 +1,131 @@ +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:eatvio_app/features/payment/presentation/payment_flow.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Widget createTestApp() { + return const MaterialApp( + localizationsDelegates: [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: [Locale('ru')], + home: Scaffold(body: SizedBox()), + ); + } + + group('showConsultationPaymentFlow', () { + late bool gateCalled; + late bool? gateResult; + late bool formCalled; + late PromoCodeResult? formResult; + late bool sbpCalled; + late int sbpPlanId; + + Future mockGate(BuildContext context) async { + gateCalled = true; + return gateResult; + } + + Future mockForm( + BuildContext context, { + Future Function(String)? onValidate, + }) async { + formCalled = true; + return formResult; + } + + Future mockSbp(BuildContext context, {required int planId}) async { + sbpCalled = true; + sbpPlanId = planId; + } + + setUp(() { + gateCalled = false; + gateResult = null; + formCalled = false; + formResult = null; + sbpCalled = false; + sbpPlanId = 0; + }); + + testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async { + gateResult = false; + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isFalse); + expect(sbpCalled, isTrue); + expect(sbpPlanId, 42); + }); + + testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async { + gateResult = true; + formResult = PromoCodeResult(); + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isTrue); + expect(sbpCalled, isTrue); + expect(sbpPlanId, 42); + }); + + testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async { + gateResult = true; + formResult = null; + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isTrue); + expect(sbpCalled, isFalse); + }); + + testWidgets('гейт отменён (null) → ничего не вызывается', (tester) async { + gateResult = null; + await tester.pumpWidget(createTestApp()); + + await showConsultationPaymentFlow( + tester.element(find.byType(SizedBox)), + planId: 42, + showGate: mockGate, + showForm: mockForm, + showSbp: mockSbp, + ); + + expect(gateCalled, isTrue); + expect(formCalled, isFalse); + expect(sbpCalled, isFalse); + }); + }); +} diff --git a/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart b/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart new file mode 100644 index 00000000..48cf3859 --- /dev/null +++ b/test/features/payment/presentation/widgets/promo_code_gate_bottom_sheet_test.dart @@ -0,0 +1,65 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PromoCodeGateBottomSheet', () { + Widget createTestWidget({required ValueChanged onResult}) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold( + body: PromoCodeGateBottomSheet(onResult: onResult), + ), + ); + } + + testWidgets('показывает заголовок, BottomSheetHandlerBar и кнопки Да/Нет', (tester) async { + await tester.pumpWidget(createTestWidget(onResult: (_) {})); + await tester.pump(); + + expect(find.byType(BottomSheetHandlerBar), findsOneWidget); + expect(find.text('У вас есть промокод?'), findsOneWidget); + expect(find.text('Да'), findsOneWidget); + expect(find.text('Нет'), findsOneWidget); + }); + + testWidgets('tap Да вызывает onResult(true)', (tester) async { + bool? result; + await tester.pumpWidget( + createTestWidget(onResult: (v) => result = v), + ); + await tester.pump(); + + await tester.tap(find.text('Да')); + await tester.pump(); + + expect(result, isTrue); + }); + + testWidgets('tap Нет вызывает onResult(false)', (tester) async { + bool? result; + await tester.pumpWidget( + createTestWidget(onResult: (v) => result = v), + ); + await tester.pump(); + + await tester.tap(find.text('Нет')); + await tester.pump(); + + expect(result, isFalse); + }); + }); +} diff --git a/test/features/payment/presentation/widgets/promo_code_modal_test.dart b/test/features/payment/presentation/widgets/promo_code_modal_test.dart new file mode 100644 index 00000000..df2721e7 --- /dev/null +++ b/test/features/payment/presentation/widgets/promo_code_modal_test.dart @@ -0,0 +1,101 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ItvioPromoCodeModal', () { + Widget createTestWidget({ + Future Function(String)? onValidate, + }) { + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => ItvioPromoCodeModal.show( + context, + onValidate: onValidate, + ), + child: const Text('Open'), + ), + ), + ); + } + + testWidgets('показывает заголовок Есть промокод? и кнопки Применить/Пропустить', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Есть промокод?'), findsOneWidget); + expect(find.text('Применить'), findsOneWidget); + expect(find.text('Пропустить'), findsOneWidget); + }); + + testWidgets('Применить с пустым полем показывает ошибку и не закрывает', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Применить')); + await tester.pump(); + + expect(find.text('Введите промокод'), findsAtLeast(1)); + expect(find.text('Применить'), findsOneWidget); + }); + + testWidgets('Применить с вводом вызывает onValidate и показывает ошибку', (tester) async { + PromoValidation? captured; + await tester.pumpWidget( + createTestWidget( + onValidate: (code) async { + captured ??= PromoValidation.failure('Промокод не найден или истёк'); + return captured!; + }, + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'TEST123'); + await tester.tap(find.text('Применить')); + await tester.pump(); + + expect(captured, isNotNull); + expect(find.text('Промокод не найден или истёк'), findsOneWidget); + expect(find.text('Применить'), findsOneWidget); + }); + + testWidgets('Пропустить закрывает шторку', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Пропустить')); + await tester.pumpAndSettle(); + + expect(find.text('Есть промокод?'), findsNothing); + }); + + testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Промокод применён!'), findsNothing); + expect(find.text('Продолжить'), findsNothing); + expect(find.text('Убрать промокод'), findsNothing); + }); + }); +} From de19af14bffef19bfebc1fe0de80adc9c1ff7fd4 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 14 Jul 2026 16:11:56 +0300 Subject: [PATCH 14/16] fix: fixed analyzer and test --- test/features/order_info/cubit/order_info_cubit_test.dart | 6 +++--- test/features/payment/consultation_payment_flow_test.dart | 2 +- .../payment/presentation/widgets/promo_code_modal_test.dart | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/features/order_info/cubit/order_info_cubit_test.dart b/test/features/order_info/cubit/order_info_cubit_test.dart index b1925972..f62ff961 100644 --- a/test/features/order_info/cubit/order_info_cubit_test.dart +++ b/test/features/order_info/cubit/order_info_cubit_test.dart @@ -200,13 +200,13 @@ void main() { .thenAnswer((_) async => chosenPlan); await cubit.getChosenPlan(id: 1); - when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'mock')) + when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank')) .thenAnswer((_) async => paymentModel); final result = await cubit.getPaymentLink(); expect(result, equals('https://payment.url')); - verify(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'mock')).called(1); + verify(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank')).called(1); }); test('getPaymentLink - возвращает null, если план не выбран', () async { @@ -254,7 +254,7 @@ void main() { .thenAnswer((_) async => chosenPlan); await cubit.getChosenPlan(id: 1); - when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'mock')) + when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank')) .thenThrow(Exception('Ошибка')); final result = await cubit.getPaymentLink(); diff --git a/test/features/payment/consultation_payment_flow_test.dart b/test/features/payment/consultation_payment_flow_test.dart index 667c84ed..f0544e6d 100644 --- a/test/features/payment/consultation_payment_flow_test.dart +++ b/test/features/payment/consultation_payment_flow_test.dart @@ -76,7 +76,7 @@ void main() { testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async { gateResult = true; - formResult = PromoCodeResult(); + formResult = const PromoCodeResult(); await tester.pumpWidget(createTestApp()); await showConsultationPaymentFlow( diff --git a/test/features/payment/presentation/widgets/promo_code_modal_test.dart b/test/features/payment/presentation/widgets/promo_code_modal_test.dart index df2721e7..e1b2e923 100644 --- a/test/features/payment/presentation/widgets/promo_code_modal_test.dart +++ b/test/features/payment/presentation/widgets/promo_code_modal_test.dart @@ -60,7 +60,7 @@ void main() { await tester.pumpWidget( createTestWidget( onValidate: (code) async { - captured ??= PromoValidation.failure('Промокод не найден или истёк'); + captured ??= const PromoValidation.failure('Промокод не найден или истёк'); return captured!; }, ), From e316bf1e0e887ff83bda2e008cf01927f20e70ff Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 14 Jul 2026 16:12:43 +0300 Subject: [PATCH 15/16] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 863f6d82..e6badd50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.23+105 +version: 1.3.24+106 publish_to: 'none' environment: From eee912f05b47a302c107514a4621de6f60ab6949 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 14 Jul 2026 16:57:32 +0300 Subject: [PATCH 16/16] fix: pub cache cleaning --- .gitea/workflows/test.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index b27aed6c..458bea8c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -152,9 +152,10 @@ jobs: - name: Get dependencies run: | - flutter clean - flutter pub get - dart run build_runner build --delete-conflicting-outputs + flutter clean + flutter pub cache repair + flutter pub get + dart run build_runner build --delete-conflicting-outputs - name: Native Analyze run: flutter analyze