新增:pets feature 数据层(T2-11,契约 v1.2.0 全 18 操作)
CI / flutter-gates (push) Successful in 1m12s

- pet_models.dart:pets 域 DTO 逐字段对齐冻结契约(宠物/品种/体重/
  疫苗目录/疫苗/健康事件/照护提醒/聚合摘要 + cursor 分页信封),
  枚举严格解析,请求体部分更新语义(缺席字段不出现)
- pets_repository.dart:PetsRepository 抽象 + ApiPetsRepository,
  12 路径 18 操作全覆盖;四个 POST 携带 Idempotency-Key;
  复用既有 ApiClient token 拦截与单飞刷新重放
- pet_exceptions.dart:新 8 错误码映射为类型化异常
  (40300/40401/40402/40902/40903/40904/42201/42202),
  仍可按 ApiBusinessException 基类捕获
- pets_controller.dart:宠物档案状态自 AppState 拆出
  (Controller → Repository → API Client 分层,四态就绪供 T2-12)
- money.dart:元/分换算工具(DTO 层保持整数分,UI 层 T2-14 使用)
- api_client.dart:新增 patbondPetApiBaseUrl(:8083,--dart-define
  可覆盖)与 query 参数支持;api_exception.dart 补 pets 域错误码
- 测试 64 → 126:DTO 映射、错误类型化映射、请求线路
  (路径/方法/鉴权/分页/幂等键/tz)、控制器状态机、金额换算

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:22:24 +08:00
parent 33b993ca0c
commit 7fb9031f8d
12 changed files with 2665 additions and 1 deletions
@@ -0,0 +1,387 @@
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<void> setUpWith(
Future<ResponseBody> 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('listPetsGET /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('createPetPOST 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:路径带 petIdPATCH 提交 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('listWeightscursor 分页参数与信封解析', () 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-Keyweights/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': <Object?>[], '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('getPetSummarytz 参数传递与四聚合解析', () 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<void> expectMapped(
int httpStatus,
int code,
TypeMatcher<ApiBusinessException> 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<PetAccessDeniedException>());
});
test('40401 → PetNotFoundException(防枚举 404', () async {
await expectMapped(404, 40401, isA<PetNotFoundException>());
});
test('40402 → PetRecordNotFoundException(记录级防枚举)', () async {
await expectMapped(404, 40402, isA<PetRecordNotFoundException>());
});
test('40902 → PetVersionConflictException', () async {
await expectMapped(409, 40902, isA<PetVersionConflictException>());
});
test('40903 → MicrochipTakenException', () async {
await expectMapped(409, 40903, isA<MicrochipTakenException>());
});
test('40904 → VaccinationDoseExistsException', () async {
await expectMapped(409, 40904, isA<VaccinationDoseExistsException>());
});
test('42201 → VaccinationRuleException', () async {
await expectMapped(422, 42201, isA<VaccinationRuleException>());
});
test('42202 → CareReminderRuleException', () async {
await expectMapped(422, 42202, isA<CareReminderRuleException>());
});
test('未覆盖码(40000)保持通用 ApiBusinessException', () async {
await setUpWith(
(options) async => jsonResponse(400, errorEnvelope(40000, '参数校验失败')),
);
await expectLater(
repository.getPet('p-1'),
throwsA(
isA<ApiBusinessException>()
.having((e) => e.code, 'code', 40000)
.having(
(e) => e,
'runtimeType',
isNot(isA<PetNotFoundException>()),
),
),
);
});
test('类型化异常仍可按基类 ApiBusinessException 捕获', () {
const error = PetVersionConflictException(message: '数据已被修改');
expect(error, isA<ApiBusinessException>());
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');
});
}