From d4a07fd7741a030e9f44b5f8c986aae6e1fd14fe Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Wed, 17 Jun 2026 13:24:28 +0330 Subject: [PATCH] test: add AI recommendations fallback tests Cover repository and view model fallback behavior and fix load state handling for 503 fallback responses. Co-authored-by: Cursor --- .../ai_recommendations_view_model.dart | 16 +- .../company_ai_repository_test.dart | 92 +++++++++ .../ai_recommendations_view_model_test.dart | 184 ++++++++++++++++++ 3 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 test/data/repositories/company_ai_repository_test.dart create mode 100644 test/view_models/ai_recommendations_view_model_test.dart diff --git a/lib/view_models/ai_recommendations_view_model.dart b/lib/view_models/ai_recommendations_view_model.dart index 16c31a0..8eac157 100644 --- a/lib/view_models/ai_recommendations_view_model.dart +++ b/lib/view_models/ai_recommendations_view_model.dart @@ -72,6 +72,8 @@ class AiRecommendationsViewModel with ChangeNotifier { final result = await _companyAiRepository.getRecommendations(); + var loadedSuccessfully = false; + switch (result) { case Ok(): _items = result.value.data ?? const []; @@ -79,28 +81,32 @@ class AiRecommendationsViewModel with ChangeNotifier { if (_items.isEmpty) { _stillLearning = _onboardingWasRecent(); } + loadedSuccessfully = true; case Error(): - await _handleError(result.error); + loadedSuccessfully = await _handleError(result.error); } _isLoading = false; - _hasLoaded = true; + _hasLoaded = loadedSuccessfully; notifyListeners(); } /// Pull-to-refresh: always re-fetches. Future refresh() => loadRecommendations(); - Future _handleError(Exception error) async { + Future _handleError(Exception error) async { // 503 means the AI service isn't configured/available — fall back to the - // legacy recommend list rather than showing an error. + // legacy recommend list rather than showing an error. NetworkManager only + // exposes this status when Dio received an HTTP response; connection and + // timeout failures have no reliable status code and should remain retryable. if (error is AppException && error.statusCode == 503) { await _loadFallback(); - return; + return _errorMessage == null; } _items = const []; _fallbackTenders = const []; _errorMessage = error.toString(); + return false; } Future _loadFallback() async { diff --git a/test/data/repositories/company_ai_repository_test.dart b/test/data/repositories/company_ai_repository_test.dart new file mode 100644 index 0000000..ecd271a --- /dev/null +++ b/test/data/repositories/company_ai_repository_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tm_app/core/constants/pref_keys.dart'; +import 'package:tm_app/core/network/network_manager.dart'; +import 'package:tm_app/core/utils/logger.dart'; +import 'package:tm_app/core/utils/result.dart'; +import 'package:tm_app/data/repositories/company_ai_repository.dart'; +import 'package:tm_app/data/services/company_ai_service.dart'; +import 'package:tm_app/data/services/model/onboarding_response/onboarding_response.dart'; +import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart'; + +void main() { + setUpAll(() { + appLogger.init(); + }); + + group('CompanyAiRepository', () { + test('skips onboarding when company has no indexable data', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = _FakeCompanyAiService(prefs); + final repository = CompanyAiRepository( + companyAiService: service, + prefs: prefs, + ); + + final outcome = await repository.maybeStartOnboarding( + documentFileIds: const [], + website: ' ', + ); + + expect(outcome, AiOnboardingOutcome.skippedNoData); + expect(service.startOnboardingCalls, 0); + expect(prefs.getString(PrefKeys.onboardingFingerprint), isNull); + }); + + test('stores fingerprint and skips unchanged profile data', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = _FakeCompanyAiService(prefs); + final repository = CompanyAiRepository( + companyAiService: service, + prefs: prefs, + ); + + final firstOutcome = await repository.maybeStartOnboarding( + documentFileIds: const ['b', 'a'], + website: ' https://example.com ', + ); + final secondOutcome = await repository.maybeStartOnboarding( + documentFileIds: const ['a', 'b'], + website: 'https://example.com', + ); + + expect(firstOutcome, AiOnboardingOutcome.started); + expect(secondOutcome, AiOnboardingOutcome.skippedUnchanged); + expect(service.startOnboardingCalls, 1); + expect( + prefs.getString(PrefKeys.onboardingFingerprint), + 'a,b|https://example.com', + ); + expect(prefs.getInt(PrefKeys.onboardingStartedAt), isNotNull); + }); + }); +} + +class _FakeCompanyAiService extends CompanyAiService { + _FakeCompanyAiService(SharedPreferences prefs) + : super(networkManager: NetworkManager(prefs)); + + int startOnboardingCalls = 0; + + @override + Future> startOnboarding() async { + startOnboardingCalls += 1; + return const Result.ok( + OnboardingResponse( + data: null, + error: null, + message: 'started', + success: true, + ), + ); + } + + @override + Future> getRecommendations() async { + return const Result.ok( + RecommendResponse(data: [], error: null, message: 'ok', success: true), + ); + } +} diff --git a/test/view_models/ai_recommendations_view_model_test.dart b/test/view_models/ai_recommendations_view_model_test.dart new file mode 100644 index 0000000..be17c9f --- /dev/null +++ b/test/view_models/ai_recommendations_view_model_test.dart @@ -0,0 +1,184 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tm_app/core/network/app_exceptions.dart'; +import 'package:tm_app/core/network/network_manager.dart'; +import 'package:tm_app/core/utils/result.dart'; +import 'package:tm_app/data/repositories/company_ai_repository.dart'; +import 'package:tm_app/data/repositories/home_repository.dart'; +import 'package:tm_app/data/services/company_ai_service.dart'; +import 'package:tm_app/data/services/home_service.dart'; +import 'package:tm_app/data/services/liked_tenders_service.dart'; +import 'package:tm_app/data/services/model/recommend_response/recommend_response.dart'; +import 'package:tm_app/data/services/model/recommendation_item/recommendation_item.dart'; +import 'package:tm_app/data/services/model/recommended_tenders_data/recommended_tenders_data.dart'; +import 'package:tm_app/data/services/model/recommended_tenders_response/recommended_tenders_response.dart'; +import 'package:tm_app/data/services/your_tenders_service.dart'; +import 'package:tm_app/view_models/ai_recommendations_view_model.dart'; + +void main() { + group('AiRecommendationsViewModel', () { + test('falls back to legacy recommendations on HTTP 503', () async { + final prefs = await _prefs(); + final companyRepository = _FakeCompanyAiRepository( + prefs, + results: [Result.error(ServerException('unavailable', 503))], + ); + final homeRepository = _FakeHomeRepository( + prefs, + result: const Result.ok( + RecommendedTendersResponse( + succes: true, + message: 'ok', + error: null, + data: RecommendedTendersData(tenders: [], metadata: null), + ), + ), + ); + final viewModel = AiRecommendationsViewModel( + companyAiRepository: companyRepository, + homeRepository: homeRepository, + ); + + await viewModel.loadRecommendations(); + + expect(viewModel.hasLoaded, isTrue); + expect(viewModel.usingFallback, isTrue); + expect(viewModel.errorMessage, isNull); + expect(homeRepository.recommendedTendersCalls, 1); + }); + + test('keeps failed transient loads retryable for loadIfNeeded', () async { + final prefs = await _prefs(); + final companyRepository = _FakeCompanyAiRepository( + prefs, + results: [ + Result.error(FetchDataException('network failed')), + const Result.ok( + RecommendResponse( + data: [ + RecommendationItem( + rank: '1', + tenderId: 'tender-1', + analysis: 'Strong match', + ), + ], + error: null, + message: 'ok', + success: true, + ), + ), + ], + ); + final viewModel = AiRecommendationsViewModel( + companyAiRepository: companyRepository, + homeRepository: _FakeHomeRepository(prefs), + ); + + await viewModel.loadRecommendations(); + await viewModel.loadIfNeeded(); + + expect(companyRepository.recommendationCalls, 2); + expect(viewModel.hasLoaded, isTrue); + expect(viewModel.errorMessage, isNull); + expect(viewModel.items.single.tenderId, 'tender-1'); + }); + + test( + 'marks empty results as still learning after recent onboarding', + () async { + final prefs = await _prefs(); + final viewModel = AiRecommendationsViewModel( + companyAiRepository: _FakeCompanyAiRepository( + prefs, + lastOnboardingStartedAt: DateTime.now().millisecondsSinceEpoch, + results: [ + const Result.ok( + RecommendResponse( + data: [], + error: null, + message: 'ok', + success: true, + ), + ), + ], + ), + homeRepository: _FakeHomeRepository(prefs), + ); + + await viewModel.loadRecommendations(); + + expect(viewModel.hasLoaded, isTrue); + expect(viewModel.stillLearning, isTrue); + expect(viewModel.isEmpty, isTrue); + }, + ); + }); +} + +Future _prefs() async { + SharedPreferences.setMockInitialValues({}); + return SharedPreferences.getInstance(); +} + +class _FakeCompanyAiRepository extends CompanyAiRepository { + _FakeCompanyAiRepository( + SharedPreferences prefs, { + required List> results, + int? lastOnboardingStartedAt, + }) : _results = results, + _lastOnboardingStartedAt = lastOnboardingStartedAt, + super(companyAiService: _UnusedCompanyAiService(prefs), prefs: prefs); + + final List> _results; + final int? _lastOnboardingStartedAt; + int recommendationCalls = 0; + + @override + Future> getRecommendations() async { + final index = recommendationCalls; + recommendationCalls += 1; + return _results[index]; + } + + @override + int? lastOnboardingStartedAt() => _lastOnboardingStartedAt; +} + +class _FakeHomeRepository extends HomeRepository { + _FakeHomeRepository( + SharedPreferences prefs, { + this.result = const Result.ok( + RecommendedTendersResponse( + succes: true, + message: 'ok', + error: null, + data: RecommendedTendersData(tenders: [], metadata: null), + ), + ), + }) : super( + homeService: HomeService(networkManager: NetworkManager(prefs)), + yourTendersService: YourTendersService( + networkManager: NetworkManager(prefs), + ), + likedTendersService: LikedTendersService( + networkManager: NetworkManager(prefs), + ), + ); + + final Result result; + int recommendedTendersCalls = 0; + + @override + Future> getHomeRecommendedTenders({ + required int limit, + required int offset, + }) async { + recommendedTendersCalls += 1; + return result; + } +} + +class _UnusedCompanyAiService extends CompanyAiService { + _UnusedCompanyAiService(SharedPreferences prefs) + : super(networkManager: NetworkManager(prefs)); +}