新增: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
+537
View File
@@ -0,0 +1,537 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
/// 契约样本(openapi.yaml v1.2.0 各 schema 的全字段实例)。
Map<String, dynamic> petJson({Map<String, Object?> overrides = const {}}) => {
'id': '019212aa-0000-7000-8000-00000000p001',
'name': '豆豆',
'species': 'dog',
'breedId': '019212aa-0000-7000-8000-00000000b001',
'breedDisplayName': '柴犬',
'customBreedName': null,
'sex': 'male',
'birthDate': '2024-03-15',
'birthDateEstimated': true,
'personality': '粘人',
'microchipNo': '900123456789012',
'sterilizedOn': '2025-06-01',
'status': 'active',
'myRole': 'owner',
'createdAt': '2026-09-01T10:00:00+08:00',
'updatedAt': '2026-09-02T10:00:00+08:00',
'version': 3,
...overrides,
};
void main() {
group('Pet', () {
test('全字段映射', () {
final pet = Pet.fromJson(petJson());
expect(pet.id, '019212aa-0000-7000-8000-00000000p001');
expect(pet.name, '豆豆');
expect(pet.species, PetSpecies.dog);
expect(pet.breedId, '019212aa-0000-7000-8000-00000000b001');
expect(pet.breedDisplayName, '柴犬');
expect(pet.customBreedName, isNull);
expect(pet.sex, PetSex.male);
expect(pet.birthDate, DateTime.parse('2024-03-15'));
expect(pet.birthDateEstimated, isTrue);
expect(pet.personality, '粘人');
expect(pet.microchipNo, '900123456789012');
expect(pet.sterilizedOn, DateTime.parse('2025-06-01'));
expect(pet.status, PetStatus.active);
expect(pet.myRole, PetRole.owner);
expect(pet.version, 3);
});
test('可空字段缺席 / null 均映射为 null(自定义品种侧)', () {
final pet = Pet.fromJson(
petJson(
overrides: {
'breedId': null,
'breedDisplayName': null,
'customBreedName': '串串',
'birthDate': null,
'personality': null,
'microchipNo': null,
'sterilizedOn': null,
},
),
);
expect(pet.breedId, isNull);
expect(pet.breedDisplayName, isNull);
expect(pet.customBreedName, '串串');
expect(pet.birthDate, isNull);
expect(pet.personality, isNull);
expect(pet.microchipNo, isNull);
expect(pet.sterilizedOn, isNull);
});
test('未知枚举取值抛 FormatException(契约漂移显式暴露)', () {
expect(
() => Pet.fromJson(petJson(overrides: {'status': 'deleted'})),
throwsFormatException,
);
expect(
() => Pet.fromJson(petJson(overrides: {'myRole': 'admin'})),
throwsFormatException,
);
});
});
group('CreatePetRequest / UpdatePetRequest', () {
test('创建请求:必填 + 可选字段序列化,日期为 YYYY-MM-DD', () {
final json = CreatePetRequest(
name: '豆豆',
species: PetSpecies.dog,
sex: PetSex.male,
breedId: 'b-1',
birthDate: DateTime(2024, 3, 5),
birthDateEstimated: false,
microchipNo: '900123456789012',
sterilizedOn: DateTime(2025, 6, 1),
).toJson();
expect(json, {
'name': '豆豆',
'species': 'dog',
'sex': 'male',
'breedId': 'b-1',
'birthDate': '2024-03-05',
'birthDateEstimated': false,
'microchipNo': '900123456789012',
'sterilizedOn': '2025-06-01',
});
});
test('创建请求:可选字段缺席不出现在请求体(部分语义)', () {
final json = const CreatePetRequest(
name: '咪咪',
species: PetSpecies.cat,
sex: PetSex.female,
customBreedName: '狸花',
).toJson();
expect(json, {
'name': '咪咪',
'species': 'cat',
'sex': 'female',
'customBreedName': '狸花',
});
});
test('更新请求:version 必带,缺席字段不出现(不支持清空回 null)', () {
final json = const UpdatePetRequest(
version: 3,
name: '豆豆二世',
status: PetStatus.lost,
).toJson();
expect(json, {'version': 3, 'name': '豆豆二世', 'status': 'lost'});
expect(json.containsKey('species'), isFalse);
});
});
group('Breed / VaccineCatalogItem', () {
test('品种映射', () {
final breed = Breed.fromJson({
'id': 'b-1',
'species': 'cat',
'code': 'ragdoll',
'displayName': '布偶猫',
});
expect(breed.id, 'b-1');
expect(breed.species, PetSpecies.cat);
expect(breed.code, 'ragdoll');
expect(breed.displayName, '布偶猫');
});
test('疫苗目录映射(description 可空)', () {
final item = VaccineCatalogItem.fromJson({
'id': 'v-1',
'code': 'rabies',
'name': '狂犬疫苗',
'species': 'dog',
'description': null,
});
expect(item.name, '狂犬疫苗');
expect(item.description, isNull);
});
});
group('WeightRecord', () {
test('全字段映射;weightKg 接受 int 与 double 下发', () {
final record = WeightRecord.fromJson({
'id': 'w-1',
'petId': 'p-1',
'weightKg': 12,
'measuredAt': '2026-09-07T09:00:00+08:00',
'source': 'clinic',
'note': '年检称重',
'createdAt': '2026-09-07T09:01:00+08:00',
});
expect(record.weightKg, 12.0);
expect(record.source, WeightSource.clinic);
expect(record.note, '年检称重');
final decimal = WeightRecord.fromJson({
'id': 'w-2',
'petId': 'p-1',
'weightKg': 4.35,
'measuredAt': '2026-09-07T09:00:00Z',
'source': 'manual',
'note': null,
'createdAt': '2026-09-07T09:01:00Z',
});
expect(decimal.weightKg, 4.35);
expect(decimal.note, isNull);
});
test('创建请求序列化(source/note 可选缺席)', () {
final json = CreateWeightRequest(
weightKg: 4.35,
measuredAt: DateTime.utc(2026, 9, 7, 1, 0),
).toJson();
expect(json['weightKg'], 4.35);
expect(json['measuredAt'], '2026-09-07T01:00:00.000Z');
expect(json.containsKey('source'), isFalse);
expect(json.containsKey('note'), isFalse);
});
});
group('Vaccination', () {
Map<String, dynamic> vaccinationJson({
Map<String, Object?> overrides = const {},
}) => {
'id': 'vx-1',
'petId': 'p-1',
'vaccineId': 'v-1',
'vaccineName': '狂犬疫苗',
'seriesKey': 'rabies-initial',
'doseNo': 2,
'doseLabel': '第二针',
'status': 'completed',
'plannedOn': '2026-08-01',
'administeredOn': '2026-08-03',
'nextDueOn': '2027-08-03',
'manufacturer': '硕腾',
'batchNo': 'B20260801',
'notes': '无不良反应',
'createdAt': '2026-08-03T10:00:00+08:00',
'updatedAt': '2026-08-03T10:00:00+08:00',
'version': 1,
...overrides,
};
test('全字段映射', () {
final record = Vaccination.fromJson(vaccinationJson());
expect(record.vaccineName, '狂犬疫苗');
expect(record.seriesKey, 'rabies-initial');
expect(record.doseNo, 2);
expect(record.status, VaccinationStatus.completed);
expect(record.administeredOn, DateTime.parse('2026-08-03'));
expect(record.nextDueOn, DateTime.parse('2027-08-03'));
expect(record.version, 1);
});
test('scheduled 态:日期可空字段为 null', () {
final record = Vaccination.fromJson(
vaccinationJson(
overrides: {
'status': 'scheduled',
'administeredOn': null,
'nextDueOn': null,
'doseLabel': null,
'manufacturer': null,
'batchNo': null,
'notes': null,
},
),
);
expect(record.status, VaccinationStatus.scheduled);
expect(record.administeredOn, isNull);
expect(record.nextDueOn, isNull);
expect(record.doseLabel, isNull);
});
test('创建请求:completed 携带 administeredOn,日期 YYYY-MM-DD', () {
final json = CreateVaccinationRequest(
vaccineId: 'v-1',
seriesKey: 'rabies-initial',
doseNo: 1,
status: VaccinationStatus.completed,
administeredOn: DateTime(2026, 8, 3),
nextDueOn: DateTime(2027, 8, 3),
).toJson();
expect(json, {
'vaccineId': 'v-1',
'seriesKey': 'rabies-initial',
'doseNo': 1,
'status': 'completed',
'administeredOn': '2026-08-03',
'nextDueOn': '2027-08-03',
});
});
test('更新请求:version 必带,仅提交的字段出现', () {
final json = const UpdateVaccinationRequest(
version: 1,
status: VaccinationStatus.cancelled,
).toJson();
expect(json, {'version': 1, 'status': 'cancelled'});
expect(json.containsKey('vaccineId'), isFalse);
});
});
group('HealthEvent', () {
test('全字段映射;amountCents 保持整数分不换算', () {
final event = HealthEvent.fromJson({
'id': 'e-1',
'petId': 'p-1',
'eventType': 'medical',
'occurredAt': '2026-09-05T14:00:00+08:00',
'title': '皮肤检查',
'notes': '轻微湿疹',
'amountCents': 12850,
'createdByUserId': 'u-1',
'createdAt': '2026-09-05T14:05:00+08:00',
'updatedAt': '2026-09-05T14:05:00+08:00',
'version': 1,
});
expect(event.eventType, HealthEventType.medical);
expect(event.title, '皮肤检查');
expect(event.amountCents, 12850);
expect(event.createdByUserId, 'u-1');
});
test('amountCents / notes 可空', () {
final event = HealthEvent.fromJson({
'id': 'e-2',
'petId': 'p-1',
'eventType': 'note',
'occurredAt': '2026-09-05T14:00:00Z',
'title': '记录',
'notes': null,
'amountCents': null,
'createdByUserId': 'u-1',
'createdAt': '2026-09-05T14:05:00Z',
'updatedAt': '2026-09-05T14:05:00Z',
'version': 1,
});
expect(event.amountCents, isNull);
expect(event.notes, isNull);
});
test('创建 / 更新请求序列化(amountCents 整数分)', () {
final create = CreateHealthEventRequest(
eventType: HealthEventType.deworming,
occurredAt: DateTime.utc(2026, 9, 5, 6, 0),
title: '体内驱虫',
amountCents: 6800,
).toJson();
expect(create, {
'eventType': 'deworming',
'occurredAt': '2026-09-05T06:00:00.000Z',
'title': '体内驱虫',
'amountCents': 6800,
});
final update = const UpdateHealthEventRequest(
version: 2,
title: '体内外驱虫',
amountCents: 9800,
).toJson();
expect(update, {'version': 2, 'title': '体内外驱虫', 'amountCents': 9800});
});
});
group('CareReminder', () {
test('全字段映射;completedAt 非空当且仅当 completed', () {
final reminder = CareReminder.fromJson({
'id': 'r-1',
'petId': 'p-1',
'reminderType': 'deworming',
'title': '体外驱虫',
'dueAt': '2026-09-15T00:00:00+08:00',
'status': 'completed',
'completedAt': '2026-09-14T20:00:00+08:00',
'createdAt': '2026-09-01T10:00:00+08:00',
'updatedAt': '2026-09-14T20:00:00+08:00',
});
expect(reminder.reminderType, CareReminderType.deworming);
expect(reminder.status, CareReminderStatus.completed);
expect(reminder.completedAt, isNotNull);
});
test('pending 态 completedAt 为 null', () {
final reminder = CareReminder.fromJson({
'id': 'r-2',
'petId': 'p-1',
'reminderType': 'checkup',
'title': '年度体检',
'dueAt': '2026-10-01T00:00:00Z',
'status': 'pending',
'completedAt': null,
'createdAt': '2026-09-01T10:00:00Z',
'updatedAt': '2026-09-01T10:00:00Z',
});
expect(reminder.status, CareReminderStatus.pending);
expect(reminder.completedAt, isNull);
});
test('创建请求恒不含 status;流转请求 dismissed 不带 completedAt', () {
final create = CreateCareReminderRequest(
reminderType: CareReminderType.medication,
title: '心丝虫药',
dueAt: DateTime.utc(2026, 10, 1),
).toJson();
expect(create, {
'reminderType': 'medication',
'title': '心丝虫药',
'dueAt': '2026-10-01T00:00:00.000Z',
});
expect(create.containsKey('status'), isFalse);
final complete = UpdateCareReminderRequest(
status: CareReminderStatus.completed,
completedAt: DateTime.utc(2026, 9, 14, 12),
).toJson();
expect(complete, {
'status': 'completed',
'completedAt': '2026-09-14T12:00:00.000Z',
});
final dismiss = const UpdateCareReminderRequest(
status: CareReminderStatus.dismissed,
).toJson();
expect(dismiss, {'status': 'dismissed'});
expect(dismiss.containsKey('completedAt'), isFalse);
});
});
group('CursorPage', () {
test('分页信封 {items, nextCursor, hasMore} 映射', () {
final page = CursorPage.fromJson({
'items': [
{
'id': 'w-1',
'petId': 'p-1',
'weightKg': 4.2,
'measuredAt': '2026-09-07T09:00:00Z',
'source': 'manual',
'note': null,
'createdAt': '2026-09-07T09:01:00Z',
},
],
'nextCursor': 'b64cursor',
'hasMore': true,
}, WeightRecord.fromJson);
expect(page.items.single.weightKg, 4.2);
expect(page.nextCursor, 'b64cursor');
expect(page.hasMore, isTrue);
});
test('末页:hasMore=false 且 nextCursor 为 null(缺席同义)', () {
final page = CursorPage.fromJson({
'items': <Object?>[],
'hasMore': false,
}, WeightRecord.fromJson);
expect(page.items, isEmpty);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
});
});
group('PetSummary', () {
test('四聚合全量映射', () {
final summary = PetSummary.fromJson({
'petId': 'p-1',
'latestWeight': {
'weightKg': 4.35,
'measuredAt': '2026-09-07T09:00:00+08:00',
},
'vaccinationProgress': {'completedDoses': 2, 'totalDoses': 3},
'nextVaccination': {
'vaccinationId': 'vx-3',
'vaccineId': 'v-1',
'vaccineName': '狂犬疫苗',
'doseNo': 3,
'doseLabel': null,
'dueOn': '2026-08-01',
'source': 'planned',
},
'monthlyExpense': {
'month': '2026-09',
'timezone': 'Asia/Shanghai',
'amountCents': 12850,
},
});
expect(summary.petId, 'p-1');
expect(summary.latestWeight!.weightKg, 4.35);
expect(summary.vaccinationProgress!.completedDoses, 2);
expect(summary.vaccinationProgress!.totalDoses, 3);
expect(summary.nextVaccination!.vaccinationId, 'vx-3');
expect(summary.nextVaccination!.doseLabel, isNull);
// dueOn 可为过去日期(逾期针仍是下一针)
expect(summary.nextVaccination!.dueOn, DateTime.parse('2026-08-01'));
expect(summary.nextVaccination!.source, NextVaccinationSource.planned);
expect(summary.monthlyExpense.month, '2026-09');
expect(summary.monthlyExpense.timezone, 'Asia/Shanghai');
expect(summary.monthlyExpense.amountCents, 12850);
});
test('null 语义:前三项无记录为 nullmonthlyExpense 恒非 null、无支出为 0', () {
final summary = PetSummary.fromJson({
'petId': 'p-1',
'latestWeight': null,
'vaccinationProgress': null,
'nextVaccination': null,
'monthlyExpense': {
'month': '2026-09',
'timezone': 'UTC',
'amountCents': 0,
},
});
expect(summary.latestWeight, isNull);
expect(summary.vaccinationProgress, isNull);
expect(summary.nextVaccination, isNull);
expect(summary.monthlyExpense.amountCents, 0);
});
test('nextDue 来源枚举', () {
final next = SummaryNextVaccination.fromJson({
'vaccinationId': 'vx-1',
'vaccineId': 'v-1',
'vaccineName': '猫三联',
'doseNo': 1,
'doseLabel': '首针',
'dueOn': '2027-08-03',
'source': 'nextDue',
});
expect(next.source, NextVaccinationSource.nextDue);
expect(next.doseLabel, '首针');
});
});
}