import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:patbond_flutter/core/network/api_client.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/core/network/token_refresher.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:patbond_flutter/features/pets/pet_exceptions.dart'; import 'package:patbond_flutter/features/pets/pet_models.dart'; import 'package:patbond_flutter/features/pets/pets_repository.dart'; import '../../helpers/auth_test_helpers.dart'; import '../../helpers/pet_test_helpers.dart'; void main() { late SessionManager session; late FakeHttpAdapter adapter; late ApiPetsRepository repository; Future setUpWith( Future Function(RequestOptions) handler, ) async { session = SessionManager(store: InMemoryTokenStore()); await session.updateTokens(sampleTokens(access: 'pet-access')); final dio = buildPatbondDio(session: session, baseUrl: 'http://pet.local'); adapter = FakeHttpAdapter(handler); dio.httpClientAdapter = adapter; final refresher = TokenRefresher(dio: dio, session: session); repository = ApiPetsRepository( api: ApiClient(dio: dio, session: session, refresher: refresher), ); } group('请求线路(路径 / 方法 / 鉴权 / 参数)', () { test('listPets:GET /api/v1/pets,携带 Bearer', () async { await setUpWith( (options) async => jsonResponse(200, okListEnvelope([samplePetJson()])), ); final pets = await repository.listPets(); final request = adapter.requests.single; expect(request.path, '/api/v1/pets'); expect(request.method, 'GET'); expect(request.headers['Authorization'], 'Bearer pet-access'); expect(pets.single.name, '豆豆'); expect(pets.single.myRole, PetRole.owner); }); test('createPet:POST 201,不带 Idempotency-Key(契约:唯一约束兜底)', () async { await setUpWith( (options) async => jsonResponse(201, okEnvelope(samplePetJson())), ); final pet = await repository.createPet( const CreatePetRequest( name: '豆豆', species: PetSpecies.dog, sex: PetSex.male, breedId: 'b-1', ), ); final request = adapter.requests.single; expect(request.method, 'POST'); expect(request.data, { 'name': '豆豆', 'species': 'dog', 'sex': 'male', 'breedId': 'b-1', }); expect(request.headers.containsKey('Idempotency-Key'), isFalse); expect(pet.id, isNotEmpty); }); test('getPet / updatePet:路径带 petId,PATCH 提交 version', () async { await setUpWith( (options) async => jsonResponse(200, okEnvelope(samplePetJson())), ); await repository.getPet('p-1'); await repository.updatePet( 'p-1', const UpdatePetRequest(version: 3, name: '豆豆二世'), ); expect(adapter.requests[0].path, '/api/v1/pets/p-1'); expect(adapter.requests[0].method, 'GET'); expect(adapter.requests[1].method, 'PATCH'); expect(adapter.requests[1].data, {'version': 3, 'name': '豆豆二世'}); }); test('字典:species 过滤参数按枚举名传递,缺省不传', () async { await setUpWith((options) async => jsonResponse(200, okListEnvelope([]))); await repository.listBreeds(species: PetSpecies.cat); await repository.listBreeds(); await repository.listVaccineCatalog(species: PetSpecies.dog); expect(adapter.requests[0].path, '/api/v1/breeds'); expect(adapter.requests[0].queryParameters, {'species': 'cat'}); expect(adapter.requests[1].queryParameters, isEmpty); expect(adapter.requests[2].path, '/api/v1/vaccine-catalog'); expect(adapter.requests[2].queryParameters, {'species': 'dog'}); }); test('listWeights:cursor 分页参数与信封解析', () async { await setUpWith( (options) async => jsonResponse( 200, okEnvelope({ 'items': [sampleWeightJson()], 'nextCursor': 'c2', 'hasMore': true, }), ), ); final page = await repository.listWeights('p-1', limit: 50, cursor: 'c1'); final request = adapter.requests.single; expect(request.path, '/api/v1/pets/p-1/weights'); expect(request.queryParameters, {'limit': 50, 'cursor': 'c1'}); expect(page.items.single.weightKg, 4.35); expect(page.nextCursor, 'c2'); expect(page.hasMore, isTrue); }); test( '四个 POST 携带 Idempotency-Key(weights/vaccinations/health-events/care-reminders)', () async { var call = 0; await setUpWith((options) async { call += 1; return switch (call) { 1 => jsonResponse(201, okEnvelope(sampleWeightJson())), 2 => jsonResponse(201, okEnvelope(sampleVaccinationJson())), 3 => jsonResponse(201, okEnvelope(sampleHealthEventJson())), _ => jsonResponse(201, okEnvelope(sampleCareReminderJson())), }; }); await repository.createWeight( 'p-1', CreateWeightRequest( weightKg: 4.35, measuredAt: DateTime.utc(2026, 9, 7), ), ); await repository.createVaccination( 'p-1', const CreateVaccinationRequest( vaccineId: 'v-1', seriesKey: 'rabies-initial', doseNo: 1, status: VaccinationStatus.scheduled, ), ); await repository.createHealthEvent( 'p-1', CreateHealthEventRequest( eventType: HealthEventType.note, occurredAt: DateTime.utc(2026, 9, 7), title: '记录', ), ); await repository.createCareReminder( 'p-1', CreateCareReminderRequest( reminderType: CareReminderType.checkup, title: '体检', dueAt: DateTime.utc(2026, 10, 1), ), ); final keys = adapter.requests .map((r) => r.headers['Idempotency-Key'] as String?) .toList(); expect(keys, everyElement(isNotNull)); expect(keys, everyElement(isNotEmpty)); // 每次逻辑提交换新键 expect(keys.toSet().length, 4); }, ); test('疫苗 / 事件 / 提醒 PATCH:顶层短路径(不含 petId)', () async { var call = 0; await setUpWith((options) async { call += 1; return switch (call) { 1 => jsonResponse(200, okEnvelope(sampleVaccinationJson())), 2 => jsonResponse(200, okEnvelope(sampleHealthEventJson())), _ => jsonResponse(200, okEnvelope(sampleCareReminderJson())), }; }); await repository.updateVaccination( 'vx-1', const UpdateVaccinationRequest( version: 1, status: VaccinationStatus.cancelled, ), ); await repository.updateHealthEvent( 'e-1', const UpdateHealthEventRequest(version: 1, title: '复查'), ); await repository.updateCareReminder( 'r-1', const UpdateCareReminderRequest(status: CareReminderStatus.dismissed), ); expect(adapter.requests[0].path, '/api/v1/vaccinations/vx-1'); expect(adapter.requests[1].path, '/api/v1/health-events/e-1'); expect(adapter.requests[2].path, '/api/v1/care-reminders/r-1'); expect(adapter.requests.map((r) => r.method), everyElement('PATCH')); }); test('listVaccinations / listCareReminders:不分页数组 + status 过滤', () async { var call = 0; await setUpWith((options) async { call += 1; return call == 1 ? jsonResponse(200, okListEnvelope([sampleVaccinationJson()])) : jsonResponse(200, okListEnvelope([sampleCareReminderJson()])); }); final vaccinations = await repository.listVaccinations('p-1'); final reminders = await repository.listCareReminders( 'p-1', status: CareReminderStatus.pending, ); expect(adapter.requests[0].path, '/api/v1/pets/p-1/vaccinations'); expect(adapter.requests[0].queryParameters, isEmpty); expect(adapter.requests[1].path, '/api/v1/pets/p-1/care-reminders'); expect(adapter.requests[1].queryParameters, {'status': 'pending'}); expect(vaccinations.single.vaccineName, '狂犬疫苗'); expect(reminders.single.status, CareReminderStatus.pending); }); test('listHealthEvents:分页参数缺省不传', () async { await setUpWith( (options) async => jsonResponse( 200, okEnvelope({'items': [], 'hasMore': false}), ), ); final page = await repository.listHealthEvents('p-1'); expect(adapter.requests.single.path, '/api/v1/pets/p-1/health-events'); expect(adapter.requests.single.queryParameters, isEmpty); expect(page.items, isEmpty); expect(page.hasMore, isFalse); }); test('getPetSummary:tz 参数传递与四聚合解析', () async { await setUpWith( (options) async => jsonResponse(200, okEnvelope(samplePetSummaryJson())), ); final summary = await repository.getPetSummary( 'p-1', tz: 'Asia/Shanghai', ); final request = adapter.requests.single; expect(request.path, '/api/v1/pets/p-1/summary'); expect(request.queryParameters, {'tz': 'Asia/Shanghai'}); expect(summary.monthlyExpense.timezone, 'Asia/Shanghai'); expect(summary.latestWeight!.weightKg, 4.35); }); }); group('错误映射(新 8 码 → 类型化异常)', () { Future expectMapped( int httpStatus, int code, TypeMatcher matcher, ) async { await setUpWith( (options) async => jsonResponse(httpStatus, errorEnvelope(code, 'err')), ); await expectLater( repository.getPet('p-1'), throwsA(matcher.having((e) => e.code, 'code', code)), ); } test('40300 → PetAccessDeniedException', () async { await expectMapped(403, 40300, isA()); }); test('40401 → PetNotFoundException(防枚举 404)', () async { await expectMapped(404, 40401, isA()); }); test('40402 → PetRecordNotFoundException(记录级防枚举)', () async { await expectMapped(404, 40402, isA()); }); test('40902 → PetVersionConflictException', () async { await expectMapped(409, 40902, isA()); }); test('40903 → MicrochipTakenException', () async { await expectMapped(409, 40903, isA()); }); test('40904 → VaccinationDoseExistsException', () async { await expectMapped(409, 40904, isA()); }); test('42201 → VaccinationRuleException', () async { await expectMapped(422, 42201, isA()); }); test('42202 → CareReminderRuleException', () async { await expectMapped(422, 42202, isA()); }); test('未覆盖码(40000)保持通用 ApiBusinessException', () async { await setUpWith( (options) async => jsonResponse(400, errorEnvelope(40000, '参数校验失败')), ); await expectLater( repository.getPet('p-1'), throwsA( isA() .having((e) => e.code, 'code', 40000) .having( (e) => e, 'runtimeType', isNot(isA()), ), ), ); }); test('类型化异常仍可按基类 ApiBusinessException 捕获', () { const error = PetVersionConflictException(message: '数据已被修改'); expect(error, isA()); expect(error.code, ApiCodes.versionConflict); }); }); group('token 拦截复用', () { test('40101:单飞刷新后重放,重放沿用同一 Idempotency-Key', () async { await setUpWith((options) async { if (options.path == '/api/v1/auth/refresh') { return jsonResponse( 200, okEnvelope(tokenDataJson(access: 'new-access')), ); } if (options.headers['Authorization'] == 'Bearer pet-access') { return jsonResponse(401, errorEnvelope(40101, 'token 过期')); } return jsonResponse(201, okEnvelope(sampleWeightJson())); }); await repository.createWeight( 'p-1', CreateWeightRequest( weightKg: 4.35, measuredAt: DateTime.utc(2026, 9, 7), ), ); final weightRequests = adapter.requests .where((r) => r.path == '/api/v1/pets/p-1/weights') .toList(); expect(weightRequests, hasLength(2)); expect(weightRequests.last.headers['Authorization'], 'Bearer new-access'); expect( weightRequests.first.headers['Idempotency-Key'], weightRequests.last.headers['Idempotency-Key'], ); }); }); test('pet 服务基地址常量存在且默认指向 :8083', () { expect(patbondPetApiBaseUrl, 'http://127.0.0.1:8083'); }); }