7fb9031f8d
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>
538 lines
16 KiB
Dart
538 lines
16 KiB
Dart
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 语义:前三项无记录为 null,monthlyExpense 恒非 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, '首针');
|
||
});
|
||
});
|
||
}
|