refactor: use GET tenders/recommend and improve tender title UX
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
Load the Recommended tab from GET /api/v1/tenders/recommend instead of the AI POST flow with 503 fallback. Fix notification pagination offset, make tender titles selectable across list and detail views, and ignore .cursor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -8,79 +7,74 @@ 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/model/tender_data/tender_data.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 {
|
||||
test('loads recommended tenders from GET /api/v1/tenders/recommend', () async {
|
||||
final prefs = await _prefs();
|
||||
final companyRepository = _FakeCompanyAiRepository(
|
||||
prefs,
|
||||
results: [Result.error(ServerException('unavailable', 503))],
|
||||
);
|
||||
final homeRepository = _FakeHomeRepository(
|
||||
prefs,
|
||||
result: const Result.ok(
|
||||
result: Result.ok(
|
||||
RecommendedTendersResponse(
|
||||
succes: true,
|
||||
message: 'ok',
|
||||
error: null,
|
||||
data: RecommendedTendersData(tenders: [], metadata: null),
|
||||
data: RecommendedTendersData(
|
||||
tenders: [_sampleTender(id: 'tender-1', title: 'Bridge repair')],
|
||||
metadata: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final viewModel = AiRecommendationsViewModel(
|
||||
companyAiRepository: companyRepository,
|
||||
companyAiRepository: _FakeCompanyAiRepository(prefs),
|
||||
homeRepository: homeRepository,
|
||||
);
|
||||
|
||||
await viewModel.loadRecommendations();
|
||||
|
||||
expect(viewModel.hasLoaded, isTrue);
|
||||
expect(viewModel.usingFallback, isTrue);
|
||||
expect(viewModel.errorMessage, isNull);
|
||||
expect(homeRepository.recommendedTendersCalls, 1);
|
||||
expect(viewModel.recommendedTenders.single.id, 'tender-1');
|
||||
});
|
||||
|
||||
test('keeps failed transient loads retryable for loadIfNeeded', () async {
|
||||
final prefs = await _prefs();
|
||||
final companyRepository = _FakeCompanyAiRepository(
|
||||
final homeRepository = _FakeHomeRepository(
|
||||
prefs,
|
||||
results: [
|
||||
Result.error(FetchDataException('network failed')),
|
||||
const Result.ok(
|
||||
RecommendResponse(
|
||||
data: [
|
||||
RecommendationItem(
|
||||
rank: '1',
|
||||
tenderId: 'tender-1',
|
||||
analysis: 'Strong match',
|
||||
),
|
||||
],
|
||||
error: null,
|
||||
Result.error(Exception('network failed')),
|
||||
Result.ok(
|
||||
RecommendedTendersResponse(
|
||||
succes: true,
|
||||
message: 'ok',
|
||||
success: true,
|
||||
error: null,
|
||||
data: RecommendedTendersData(
|
||||
tenders: [_sampleTender(id: 'tender-1', title: 'Bridge repair')],
|
||||
metadata: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final viewModel = AiRecommendationsViewModel(
|
||||
companyAiRepository: companyRepository,
|
||||
homeRepository: _FakeHomeRepository(prefs),
|
||||
companyAiRepository: _FakeCompanyAiRepository(prefs),
|
||||
homeRepository: homeRepository,
|
||||
);
|
||||
|
||||
await viewModel.loadRecommendations();
|
||||
await viewModel.loadIfNeeded();
|
||||
|
||||
expect(companyRepository.recommendationCalls, 2);
|
||||
expect(homeRepository.recommendedTendersCalls, 2);
|
||||
expect(viewModel.hasLoaded, isTrue);
|
||||
expect(viewModel.errorMessage, isNull);
|
||||
expect(viewModel.items.single.tenderId, 'tender-1');
|
||||
expect(viewModel.recommendedTenders.single.id, 'tender-1');
|
||||
});
|
||||
|
||||
test(
|
||||
@@ -91,16 +85,6 @@ void main() {
|
||||
companyAiRepository: _FakeCompanyAiRepository(
|
||||
prefs,
|
||||
lastOnboardingStartedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
results: [
|
||||
const Result.ok(
|
||||
RecommendResponse(
|
||||
data: [],
|
||||
error: null,
|
||||
message: 'ok',
|
||||
success: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
homeRepository: _FakeHomeRepository(prefs),
|
||||
);
|
||||
@@ -123,22 +107,11 @@ Future<SharedPreferences> _prefs() async {
|
||||
class _FakeCompanyAiRepository extends CompanyAiRepository {
|
||||
_FakeCompanyAiRepository(
|
||||
SharedPreferences prefs, {
|
||||
required List<Result<RecommendResponse>> results,
|
||||
int? lastOnboardingStartedAt,
|
||||
}) : _results = results,
|
||||
_lastOnboardingStartedAt = lastOnboardingStartedAt,
|
||||
}) : _lastOnboardingStartedAt = lastOnboardingStartedAt,
|
||||
super(companyAiService: _UnusedCompanyAiService(prefs), prefs: prefs);
|
||||
|
||||
final List<Result<RecommendResponse>> _results;
|
||||
final int? _lastOnboardingStartedAt;
|
||||
int recommendationCalls = 0;
|
||||
|
||||
@override
|
||||
Future<Result<RecommendResponse>> getRecommendations() async {
|
||||
final index = recommendationCalls;
|
||||
recommendationCalls += 1;
|
||||
return _results[index];
|
||||
}
|
||||
|
||||
@override
|
||||
int? lastOnboardingStartedAt() => _lastOnboardingStartedAt;
|
||||
@@ -147,15 +120,22 @@ class _FakeCompanyAiRepository extends CompanyAiRepository {
|
||||
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(
|
||||
List<Result<RecommendedTendersResponse>>? results,
|
||||
Result<RecommendedTendersResponse>? result,
|
||||
}) : _results =
|
||||
results ??
|
||||
[
|
||||
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),
|
||||
@@ -165,7 +145,7 @@ class _FakeHomeRepository extends HomeRepository {
|
||||
),
|
||||
);
|
||||
|
||||
final Result<RecommendedTendersResponse> result;
|
||||
final List<Result<RecommendedTendersResponse>> _results;
|
||||
int recommendedTendersCalls = 0;
|
||||
|
||||
@override
|
||||
@@ -173,8 +153,9 @@ class _FakeHomeRepository extends HomeRepository {
|
||||
required int limit,
|
||||
required int offset,
|
||||
}) async {
|
||||
final index = recommendedTendersCalls;
|
||||
recommendedTendersCalls += 1;
|
||||
return result;
|
||||
return _results[index];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,3 +163,36 @@ class _UnusedCompanyAiService extends CompanyAiService {
|
||||
_UnusedCompanyAiService(SharedPreferences prefs)
|
||||
: super(networkManager: NetworkManager(prefs));
|
||||
}
|
||||
|
||||
TenderData _sampleTender({required String id, required String title}) {
|
||||
return TenderData(
|
||||
success: null,
|
||||
message: null,
|
||||
id: id,
|
||||
tenderId: id,
|
||||
noticePublicationId: null,
|
||||
noticeTypeCode: null,
|
||||
formType: null,
|
||||
noticeLanguageCode: null,
|
||||
issueDate: null,
|
||||
issueTime: null,
|
||||
publicationDate: null,
|
||||
title: title,
|
||||
description: null,
|
||||
procurementTypeCode: null,
|
||||
procedureCode: null,
|
||||
estimatedValue: null,
|
||||
currency: null,
|
||||
duration: null,
|
||||
tenderDeadline: null,
|
||||
submissionDeadline: null,
|
||||
applicationDeadline: null,
|
||||
submissionUrl: null,
|
||||
countryCode: null,
|
||||
durationUnit: null,
|
||||
buyerOrganization: null,
|
||||
lots: null,
|
||||
officialLanguages: null,
|
||||
status: null,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user