Files
patbond-flutter/test/features/pets/pet_models_test.dart
T
lixi 7fb9031f8d
CI / flutter-gates (push) Successful in 1m12s
新增:pets feature 数据层(T2-11,契约 v1.2.0 全 18 操作)
- 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>
2026-09-08 11:22:24 +08:00

538 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '首针');
});
});
}