新增: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
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/pets/money.dart';
void main() {
group('formatCentsAsYuan', () {
test('整元不带小数', () {
expect(formatCentsAsYuan(0), '0');
expect(formatCentsAsYuan(100), '1');
expect(formatCentsAsYuan(12800), '128');
});
test('非整元固定两位小数', () {
expect(formatCentsAsYuan(12850), '128.50');
expect(formatCentsAsYuan(12805), '128.05');
expect(formatCentsAsYuan(5), '0.05');
expect(formatCentsAsYuan(50), '0.50');
});
test('负数入参抛 ArgumentError(契约金额非负)', () {
expect(() => formatCentsAsYuan(-1), throwsArgumentError);
});
});
group('parseYuanToCents', () {
test('整数与一/两位小数', () {
expect(parseYuanToCents('128'), 12800);
expect(parseYuanToCents('128.5'), 12850);
expect(parseYuanToCents('128.50'), 12850);
expect(parseYuanToCents('0.05'), 5);
expect(parseYuanToCents('0'), 0);
expect(parseYuanToCents(' 12.30 '), 1230);
});
test('非法输入返回 null', () {
expect(parseYuanToCents(''), isNull);
expect(parseYuanToCents('abc'), isNull);
expect(parseYuanToCents('12.345'), isNull);
expect(parseYuanToCents('-5'), isNull);
expect(parseYuanToCents('12.'), isNull);
expect(parseYuanToCents('.5'), isNull);
expect(parseYuanToCents('1,200'), isNull);
});
test('往返一致:format(parse(x)) 保持数值', () {
for (final input in ['128', '128.50', '0.05', '99.90']) {
final cents = parseYuanToCents(input)!;
expect(parseYuanToCents(formatCentsAsYuan(cents)), cents);
}
});
});
}
+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, '首针');
});
});
}
@@ -0,0 +1,167 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.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_controller.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import '../../helpers/pet_test_helpers.dart';
/// 假仓库:各方法可注入行为,未注入的方法抛 UnimplementedError。
class FakePetsRepository implements PetsRepository {
Future<List<Pet>> Function()? listPetsHandler;
Future<Pet> Function(CreatePetRequest)? createPetHandler;
Future<Pet> Function(String)? getPetHandler;
Future<Pet> Function(String, UpdatePetRequest)? updatePetHandler;
@override
Future<List<Pet>> listPets() =>
listPetsHandler?.call() ?? Future.value(const []);
@override
Future<Pet> createPet(CreatePetRequest request) => createPetHandler!(request);
@override
Future<Pet> getPet(String petId) => getPetHandler!(petId);
@override
Future<Pet> updatePet(String petId, UpdatePetRequest request) =>
updatePetHandler!(petId, request);
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError('${invocation.memberName}');
}
Pet pet(String id, {String name = '豆豆', int version = 1}) => Pet.fromJson(
samplePetJson(overrides: {'id': id, 'name': name, 'version': version}),
);
void main() {
late FakePetsRepository repository;
late PetsController controller;
setUp(() {
repository = FakePetsRepository();
controller = PetsController(repository: repository);
});
test('初始态为 initial,不发请求', () {
expect(controller.phase, PetsLoadPhase.initial);
expect(controller.pets, isEmpty);
expect(controller.isEmpty, isFalse);
});
test('refresh 成功:loading → ready,列表就位', () async {
repository.listPetsHandler = () async => [pet('p-1'), pet('p-2')];
final phases = <PetsLoadPhase>[];
controller.addListener(() => phases.add(controller.phase));
await controller.refresh();
expect(phases, [PetsLoadPhase.loading, PetsLoadPhase.ready]);
expect(controller.pets.map((p) => p.id), ['p-1', 'p-2']);
expect(controller.isEmpty, isFalse);
expect(controller.lastError, isNull);
});
test('refresh 空列表:ready 且 isEmpty(新用户空态)', () async {
repository.listPetsHandler = () async => [];
await controller.refresh();
expect(controller.phase, PetsLoadPhase.ready);
expect(controller.isEmpty, isTrue);
});
test('refresh 失败:error 态保留类型化错误,重试可恢复', () async {
repository.listPetsHandler = () async =>
throw const ApiNetworkException('断网');
await controller.refresh();
expect(controller.phase, PetsLoadPhase.error);
expect(controller.lastError, isA<ApiNetworkException>());
repository.listPetsHandler = () async => [pet('p-1')];
await controller.refresh();
expect(controller.phase, PetsLoadPhase.ready);
expect(controller.lastError, isNull);
expect(controller.pets, hasLength(1));
});
test('createPet:成功后插入列表头', () async {
repository.listPetsHandler = () async => [pet('p-1')];
await controller.refresh();
repository.createPetHandler = (request) async => pet('p-2', name: '咪咪');
final created = await controller.createPet(
const CreatePetRequest(
name: '咪咪',
species: PetSpecies.cat,
sex: PetSex.female,
customBreedName: '狸花',
),
);
expect(created.id, 'p-2');
expect(controller.pets.map((p) => p.id), ['p-2', 'p-1']);
});
test('createPet 失败(40903):类型化异常外抛,列表不变', () async {
repository.listPetsHandler = () async => [pet('p-1')];
await controller.refresh();
repository.createPetHandler = (request) async =>
throw const MicrochipTakenException(message: '芯片号已被登记');
await expectLater(
controller.createPet(
const CreatePetRequest(
name: '咪咪',
species: PetSpecies.cat,
sex: PetSex.female,
customBreedName: '狸花',
),
),
throwsA(isA<MicrochipTakenException>()),
);
expect(controller.pets, hasLength(1));
});
test('updatePet:成功后同步列表内存副本', () async {
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
await controller.refresh();
repository.updatePetHandler = (petId, request) async =>
pet('p-1', name: '豆豆二世', version: 2);
await controller.updatePet(
'p-1',
const UpdatePetRequest(version: 1, name: '豆豆二世'),
);
expect(controller.pets.single.name, '豆豆二世');
expect(controller.pets.single.version, 2);
});
test('updatePet 版本冲突(40902):异常外抛供页面提示刷新重提', () async {
repository.updatePetHandler = (petId, request) async =>
throw const PetVersionConflictException(message: '数据已被修改');
await expectLater(
controller.updatePet('p-1', const UpdatePetRequest(version: 1)),
throwsA(isA<PetVersionConflictException>()),
);
});
test('getPet:详情结果回写列表副本', () async {
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
await controller.refresh();
repository.getPetHandler = (petId) async => pet('p-1', version: 5);
final detail = await controller.getPet('p-1');
expect(detail.version, 5);
expect(controller.pets.single.version, 5);
});
}
@@ -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');
});
}