diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 545afdb..d85cb12 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -17,6 +17,14 @@ const String patbondUserApiBaseUrl = String.fromEnvironment( defaultValue: 'http://127.0.0.1:8082', ); +/// pet 服务基地址(/api/v1/pets、/api/v1/breeds、/api/v1/vaccine-catalog 等 +/// pets 域 12 路径):沿用 auth :8081 / user :8082 的分端口直连模式, +/// `--dart-define=PATBOND_PET_API_BASE_URL=...` 注入。 +const String patbondPetApiBaseUrl = String.fromEnvironment( + 'PATBOND_PET_API_BASE_URL', + defaultValue: 'http://127.0.0.1:8083', +); + /// 构建全局共用的 Dio 实例。 /// /// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成 @@ -82,6 +90,7 @@ class ApiClient { String path, { String method = 'POST', Object? body, + Map? query, Map? headers, bool requiresAuth = false, }) async { @@ -89,6 +98,7 @@ class ApiClient { path, method: method, body: body, + query: query, headers: headers, requiresAuth: requiresAuth, ); @@ -99,6 +109,7 @@ class ApiClient { path, method: method, body: body, + query: query, headers: headers, requiresAuth: requiresAuth, ); @@ -116,12 +127,14 @@ class ApiClient { required String method, required bool requiresAuth, Object? body, + Map? query, Map? headers, }) async { try { return await _dio.request( path, data: body, + queryParameters: query, options: Options( method: method, headers: headers, diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart index de6fe78..0f1d530 100644 --- a/lib/core/network/api_exception.dart +++ b/lib/core/network/api_exception.dart @@ -22,6 +22,32 @@ abstract final class ApiCodes { /// 登录失败次数过多,账号临时锁定(HTTP 423,见 openapi.yaml)。 static const loginLocked = 42300; + + // ------ pets 域(契约 v1.2.0 冻结,M2 第二波定型 8 个)------ + + /// 对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。 + static const petAccessDenied = 40300; + + /// 宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。 + static const petNotFound = 40401; + + /// 记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。 + static const recordNotFound = 40402; + + /// 乐观锁版本冲突(提交的 version 已过期;提醒的条件更新守卫落空同码)。 + static const versionConflict = 40902; + + /// 芯片号已被登记(跨用户唯一)。 + static const microchipTaken = 40903; + + /// 同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在。 + static const vaccinationDoseExists = 40904; + + /// 疫苗状态机 / 状态-日期规则违反(HTTP 422)。 + static const vaccinationRuleViolation = 42201; + + /// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。 + static const careReminderRuleViolation = 42202; } /// API 调用的类型化异常。页面按类型映射为三层错误呈现 @@ -43,7 +69,8 @@ final class ApiNetworkException extends ApiException { } /// 业务错误:错误信封 code != 0(40000/40100/40900/40901 等)。 -final class ApiBusinessException extends ApiException { +/// 领域层可按错误码细分子类型(见 features/pets/pet_exceptions.dart)。 +class ApiBusinessException extends ApiException { const ApiBusinessException({required this.code, required String message}) : super(message); diff --git a/lib/features/pets/money.dart b/lib/features/pets/money.dart new file mode 100644 index 0000000..3915bee --- /dev/null +++ b/lib/features/pets/money.dart @@ -0,0 +1,30 @@ +/// 金额换算工具:契约传输整数分(amountCents),UI 展示以元。 +/// DTO 层保持整数分不换算(开发计划 §4.3);本工具供 UI 层(T2-14)使用。 +library; + +/// 整数分 → 元展示字符串。整元不带小数("128"), +/// 非整元固定两位("128.50")。负数入参非法(契约金额非负)。 +String formatCentsAsYuan(int cents) { + if (cents < 0) { + throw ArgumentError.value(cents, 'cents', '金额不得为负'); + } + final yuan = cents ~/ 100; + final remainder = cents % 100; + if (remainder == 0) return '$yuan'; + return '$yuan.${remainder.toString().padLeft(2, '0')}'; +} + +/// 元输入串 → 整数分。接受整数或最多两位小数("12"、"12.5"、"12.50"); +/// 空白、非法字符、超两位小数、负数返回 null(表单层据此报「金额格式不正确」)。 +int? parseYuanToCents(String input) { + final trimmed = input.trim(); + final match = RegExp(r'^(\d+)(?:\.(\d{1,2}))?$').firstMatch(trimmed); + if (match == null) return null; + final yuan = int.parse(match.group(1)!); + final fractional = match.group(2); + final cents = switch (fractional) { + null => 0, + _ => int.parse(fractional.padRight(2, '0')), + }; + return yuan * 100 + cents; +} diff --git a/lib/features/pets/pet_exceptions.dart b/lib/features/pets/pet_exceptions.dart new file mode 100644 index 0000000..dceba90 --- /dev/null +++ b/lib/features/pets/pet_exceptions.dart @@ -0,0 +1,83 @@ +import 'package:patbond_flutter/core/network/api_exception.dart'; + +/// pets 域类型化业务异常(契约 v1.2.0 定型的 8 个新错误码, +/// 21 号收口报告 §1)。全部继承 [ApiBusinessException], +/// 既有按基类捕获的通用错误处理不受影响。 + +/// 40300:对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。 +final class PetAccessDeniedException extends ApiBusinessException { + const PetAccessDeniedException({required super.message}) + : super(code: ApiCodes.petAccessDenied); +} + +/// 40401:宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。 +final class PetNotFoundException extends ApiBusinessException { + const PetNotFoundException({required super.message}) + : super(code: ApiCodes.petNotFound); +} + +/// 40402:记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。 +final class PetRecordNotFoundException extends ApiBusinessException { + const PetRecordNotFoundException({required super.message}) + : super(code: ApiCodes.recordNotFound); +} + +/// 40902:乐观锁版本冲突(version 过期;提醒的条件更新守卫落空同码)。 +/// 客户端处理:刷新取新数据后重提。 +final class PetVersionConflictException extends ApiBusinessException { + const PetVersionConflictException({required super.message}) + : super(code: ApiCodes.versionConflict); +} + +/// 40903:芯片号已被登记(跨用户唯一)。 +final class MicrochipTakenException extends ApiBusinessException { + const MicrochipTakenException({required super.message}) + : super(code: ApiCodes.microchipTaken); +} + +/// 40904:同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在 +/// (cancel 后同剂次可重新登记)。 +final class VaccinationDoseExistsException extends ApiBusinessException { + const VaccinationDoseExistsException({required super.message}) + : super(code: ApiCodes.vaccinationDoseExists); +} + +/// 42201:疫苗状态机非法迁移或状态-日期规则违反(HTTP 422)。 +final class VaccinationRuleException extends ApiBusinessException { + const VaccinationRuleException({required super.message}) + : super(code: ApiCodes.vaccinationRuleViolation); +} + +/// 42202:提醒状态机非法迁移或 completed-completedAt 一致性违反(HTTP 422)。 +final class CareReminderRuleException extends ApiBusinessException { + const CareReminderRuleException({required super.message}) + : super(code: ApiCodes.careReminderRuleViolation); +} + +/// 把通用业务异常按 pets 域错误码升格为类型化异常; +/// 未覆盖的码(40000 参数错误等)原样返回,沿用通用处理。 +ApiBusinessException mapPetBusinessException(ApiBusinessException error) { + return switch (error.code) { + ApiCodes.petAccessDenied => PetAccessDeniedException( + message: error.message, + ), + ApiCodes.petNotFound => PetNotFoundException(message: error.message), + ApiCodes.recordNotFound => PetRecordNotFoundException( + message: error.message, + ), + ApiCodes.versionConflict => PetVersionConflictException( + message: error.message, + ), + ApiCodes.microchipTaken => MicrochipTakenException(message: error.message), + ApiCodes.vaccinationDoseExists => VaccinationDoseExistsException( + message: error.message, + ), + ApiCodes.vaccinationRuleViolation => VaccinationRuleException( + message: error.message, + ), + ApiCodes.careReminderRuleViolation => CareReminderRuleException( + message: error.message, + ), + _ => error, + }; +} diff --git a/lib/features/pets/pet_models.dart b/lib/features/pets/pet_models.dart new file mode 100644 index 0000000..ff063f2 --- /dev/null +++ b/lib/features/pets/pet_models.dart @@ -0,0 +1,854 @@ +/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0, +/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException] +/// 以便契约漂移在测试期暴露而非静默吞掉)。 +library; + +/// 物种(创建即定,不可修改)。 +enum PetSpecies { + dog, + cat, + other; + + static PetSpecies fromJson(String value) => + _enumFromJson(values, value, 'species'); +} + +/// 性别。 +enum PetSex { + male, + female, + unknown; + + static PetSex fromJson(String value) => _enumFromJson(values, value, 'sex'); +} + +/// 宠物状态(deleted 为内部软删态,接口永不返回)。 +enum PetStatus { + active, + lost, + deceased, + archived; + + static PetStatus fromJson(String value) => + _enumFromJson(values, value, 'status'); +} + +/// 调用者对宠物的权限角色(客户端据此显隐写入口)。 +enum PetRole { + owner, + caregiver, + viewer; + + static PetRole fromJson(String value) => + _enumFromJson(values, value, 'myRole'); +} + +/// 体重记录来源。 +enum WeightSource { + manual, + clinic, + device; + + static WeightSource fromJson(String value) => + _enumFromJson(values, value, 'source'); +} + +/// 疫苗记录状态。 +enum VaccinationStatus { + scheduled, + completed, + cancelled; + + static VaccinationStatus fromJson(String value) => + _enumFromJson(values, value, 'status'); +} + +/// 健康事件六类类型。 +enum HealthEventType { + medical, + feeding, + deworming, + grooming, + measurement, + note; + + static HealthEventType fromJson(String value) => + _enumFromJson(values, value, 'eventType'); +} + +/// 照护提醒四类类型。 +enum CareReminderType { + deworming, + checkup, + medication, + other; + + static CareReminderType fromJson(String value) => + _enumFromJson(values, value, 'reminderType'); +} + +/// 照护提醒状态。 +enum CareReminderStatus { + pending, + completed, + dismissed; + + static CareReminderStatus fromJson(String value) => + _enumFromJson(values, value, 'status'); +} + +/// 摘要「下次接种」的取值来源。 +enum NextVaccinationSource { + planned, + nextDue; + + static NextVaccinationSource fromJson(String value) => + _enumFromJson(values, value, 'source'); +} + +T _enumFromJson(List values, String raw, String field) { + for (final value in values) { + if (value.name == raw) return value; + } + throw FormatException('未知的 $field 取值:$raw'); +} + +/// 日期字段(契约 format: date)序列化为 YYYY-MM-DD。 +String dateToJson(DateTime date) { + final y = date.year.toString().padLeft(4, '0'); + final m = date.month.toString().padLeft(2, '0'); + final d = date.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; +} + +DateTime? _dateOrNull(Object? value) => + value == null ? null : DateTime.parse(value as String); + +/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(体重、健康事件)。 +class CursorPage { + const CursorPage({ + required this.items, + required this.nextCursor, + required this.hasMore, + }); + + factory CursorPage.fromJson( + Map json, + T Function(Map) itemFromJson, + ) { + return CursorPage( + items: (json['items'] as List) + .map((item) => itemFromJson(item as Map)) + .toList(), + // 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。 + nextCursor: json['nextCursor'] as String?, + hasMore: json['hasMore'] as bool, + ); + } + + final List items; + final String? nextCursor; + final bool hasMore; +} + +/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。 +/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。 +class Pet { + const Pet({ + required this.id, + required this.name, + required this.species, + required this.breedId, + required this.breedDisplayName, + required this.customBreedName, + required this.sex, + required this.birthDate, + required this.birthDateEstimated, + required this.personality, + required this.microchipNo, + required this.sterilizedOn, + required this.status, + required this.myRole, + required this.createdAt, + required this.updatedAt, + required this.version, + }); + + factory Pet.fromJson(Map json) { + return Pet( + id: json['id'] as String, + name: json['name'] as String, + species: PetSpecies.fromJson(json['species'] as String), + breedId: json['breedId'] as String?, + breedDisplayName: json['breedDisplayName'] as String?, + customBreedName: json['customBreedName'] as String?, + sex: PetSex.fromJson(json['sex'] as String), + birthDate: _dateOrNull(json['birthDate']), + birthDateEstimated: json['birthDateEstimated'] as bool, + personality: json['personality'] as String?, + microchipNo: json['microchipNo'] as String?, + sterilizedOn: _dateOrNull(json['sterilizedOn']), + status: PetStatus.fromJson(json['status'] as String), + myRole: PetRole.fromJson(json['myRole'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + version: json['version'] as int, + ); + } + + final String id; + final String name; + final PetSpecies species; + final String? breedId; + final String? breedDisplayName; + final String? customBreedName; + final PetSex sex; + final DateTime? birthDate; + final bool birthDateEstimated; + final String? personality; + final String? microchipNo; + final DateTime? sterilizedOn; + final PetStatus status; + final PetRole myRole; + final DateTime createdAt; + final DateTime updatedAt; + final int version; +} + +/// 创建宠物请求。breedId 与 customBreedName 二选一且互斥(400/40000 兜底在服务端)。 +class CreatePetRequest { + const CreatePetRequest({ + required this.name, + required this.species, + required this.sex, + this.breedId, + this.customBreedName, + this.birthDate, + this.birthDateEstimated, + this.personality, + this.microchipNo, + this.sterilizedOn, + }); + + final String name; + final PetSpecies species; + final PetSex sex; + final String? breedId; + final String? customBreedName; + final DateTime? birthDate; + final bool? birthDateEstimated; + final String? personality; + final String? microchipNo; + final DateTime? sterilizedOn; + + Map toJson() => { + 'name': name, + 'species': species.name, + 'sex': sex.name, + if (breedId != null) 'breedId': breedId, + if (customBreedName != null) 'customBreedName': customBreedName, + if (birthDate != null) 'birthDate': dateToJson(birthDate!), + if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated, + if (personality != null) 'personality': personality, + if (microchipNo != null) 'microchipNo': microchipNo, + if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!), + }; +} + +/// 更新宠物请求(部分更新:缺席字段不变;不支持清空回 null; +/// 品种对整体替换;species 不可改;version 乐观锁必填)。 +class UpdatePetRequest { + const UpdatePetRequest({ + required this.version, + this.name, + this.breedId, + this.customBreedName, + this.sex, + this.birthDate, + this.birthDateEstimated, + this.personality, + this.microchipNo, + this.sterilizedOn, + this.status, + }); + + final int version; + final String? name; + final String? breedId; + final String? customBreedName; + final PetSex? sex; + final DateTime? birthDate; + final bool? birthDateEstimated; + final String? personality; + final String? microchipNo; + final DateTime? sterilizedOn; + final PetStatus? status; + + Map toJson() => { + 'version': version, + if (name != null) 'name': name, + if (breedId != null) 'breedId': breedId, + if (customBreedName != null) 'customBreedName': customBreedName, + if (sex != null) 'sex': sex!.name, + if (birthDate != null) 'birthDate': dateToJson(birthDate!), + if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated, + if (personality != null) 'personality': personality, + if (microchipNo != null) 'microchipNo': microchipNo, + if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!), + if (status != null) 'status': status!.name, + }; +} + +/// 品种目录项(只读字典)。 +class Breed { + const Breed({ + required this.id, + required this.species, + required this.code, + required this.displayName, + }); + + factory Breed.fromJson(Map json) { + return Breed( + id: json['id'] as String, + species: PetSpecies.fromJson(json['species'] as String), + code: json['code'] as String, + displayName: json['displayName'] as String, + ); + } + + final String id; + final PetSpecies species; + final String code; + final String displayName; +} + +/// 体重记录(append-only,无乐观锁)。 +class WeightRecord { + const WeightRecord({ + required this.id, + required this.petId, + required this.weightKg, + required this.measuredAt, + required this.source, + required this.note, + required this.createdAt, + }); + + factory WeightRecord.fromJson(Map json) { + return WeightRecord( + id: json['id'] as String, + petId: json['petId'] as String, + // 服务端 numeric(6,2),整数值也可能以 int 下发。 + weightKg: (json['weightKg'] as num).toDouble(), + measuredAt: DateTime.parse(json['measuredAt'] as String), + source: WeightSource.fromJson(json['source'] as String), + note: json['note'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + final String id; + final String petId; + final double weightKg; + final DateTime measuredAt; + final WeightSource source; + final String? note; + final DateTime createdAt; +} + +/// 添加体重记录请求。 +class CreateWeightRequest { + const CreateWeightRequest({ + required this.weightKg, + required this.measuredAt, + this.source, + this.note, + }); + + final double weightKg; + final DateTime measuredAt; + final WeightSource? source; + final String? note; + + Map toJson() => { + 'weightKg': weightKg, + 'measuredAt': measuredAt.toIso8601String(), + if (source != null) 'source': source!.name, + if (note != null) 'note': note, + }; +} + +/// 疫苗目录项(只读字典)。 +class VaccineCatalogItem { + const VaccineCatalogItem({ + required this.id, + required this.code, + required this.name, + required this.species, + required this.description, + }); + + factory VaccineCatalogItem.fromJson(Map json) { + return VaccineCatalogItem( + id: json['id'] as String, + code: json['code'] as String, + name: json['name'] as String, + species: PetSpecies.fromJson(json['species'] as String), + description: json['description'] as String?, + ); + } + + final String id; + final String code; + final String name; + final PetSpecies species; + final String? description; +} + +/// 疫苗记录。vaccineName 由目录解出(列表页免二次查字典)。 +class Vaccination { + const Vaccination({ + required this.id, + required this.petId, + required this.vaccineId, + required this.vaccineName, + required this.seriesKey, + required this.doseNo, + required this.doseLabel, + required this.status, + required this.plannedOn, + required this.administeredOn, + required this.nextDueOn, + required this.manufacturer, + required this.batchNo, + required this.notes, + required this.createdAt, + required this.updatedAt, + required this.version, + }); + + factory Vaccination.fromJson(Map json) { + return Vaccination( + id: json['id'] as String, + petId: json['petId'] as String, + vaccineId: json['vaccineId'] as String, + vaccineName: json['vaccineName'] as String, + seriesKey: json['seriesKey'] as String, + doseNo: json['doseNo'] as int, + doseLabel: json['doseLabel'] as String?, + status: VaccinationStatus.fromJson(json['status'] as String), + plannedOn: _dateOrNull(json['plannedOn']), + administeredOn: _dateOrNull(json['administeredOn']), + nextDueOn: _dateOrNull(json['nextDueOn']), + manufacturer: json['manufacturer'] as String?, + batchNo: json['batchNo'] as String?, + notes: json['notes'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + version: json['version'] as int, + ); + } + + final String id; + final String petId; + final String vaccineId; + final String vaccineName; + final String seriesKey; + final int doseNo; + final String? doseLabel; + final VaccinationStatus status; + final DateTime? plannedOn; + final DateTime? administeredOn; + final DateTime? nextDueOn; + final String? manufacturer; + final String? batchNo; + final String? notes; + final DateTime createdAt; + final DateTime updatedAt; + final int version; +} + +/// 创建疫苗记录请求(创建状态仅 scheduled / completed)。 +class CreateVaccinationRequest { + const CreateVaccinationRequest({ + required this.vaccineId, + required this.seriesKey, + required this.doseNo, + required this.status, + this.doseLabel, + this.plannedOn, + this.administeredOn, + this.nextDueOn, + this.manufacturer, + this.batchNo, + this.notes, + }); + + final String vaccineId; + final String seriesKey; + final int doseNo; + + /// 契约允许 scheduled / completed(cancelled 创建无业务意义,服务端 400)。 + final VaccinationStatus status; + final String? doseLabel; + final DateTime? plannedOn; + final DateTime? administeredOn; + final DateTime? nextDueOn; + final String? manufacturer; + final String? batchNo; + final String? notes; + + Map toJson() => { + 'vaccineId': vaccineId, + 'seriesKey': seriesKey, + 'doseNo': doseNo, + 'status': status.name, + if (doseLabel != null) 'doseLabel': doseLabel, + if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!), + if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!), + if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!), + if (manufacturer != null) 'manufacturer': manufacturer, + if (batchNo != null) 'batchNo': batchNo, + if (notes != null) 'notes': notes, + }; +} + +/// 更新疫苗记录请求(vaccineId / seriesKey / doseNo 不可改;version 必填)。 +class UpdateVaccinationRequest { + const UpdateVaccinationRequest({ + required this.version, + this.status, + this.plannedOn, + this.administeredOn, + this.nextDueOn, + this.doseLabel, + this.manufacturer, + this.batchNo, + this.notes, + }); + + final int version; + final VaccinationStatus? status; + final DateTime? plannedOn; + final DateTime? administeredOn; + final DateTime? nextDueOn; + final String? doseLabel; + final String? manufacturer; + final String? batchNo; + final String? notes; + + Map toJson() => { + 'version': version, + if (status != null) 'status': status!.name, + if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!), + if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!), + if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!), + if (doseLabel != null) 'doseLabel': doseLabel, + if (manufacturer != null) 'manufacturer': manufacturer, + if (batchNo != null) 'batchNo': batchNo, + if (notes != null) 'notes': notes, + }; +} + +/// 健康事件(六类;金额整数分传输,元/分换算留给 UI 层)。 +class HealthEvent { + const HealthEvent({ + required this.id, + required this.petId, + required this.eventType, + required this.occurredAt, + required this.title, + required this.notes, + required this.amountCents, + required this.createdByUserId, + required this.createdAt, + required this.updatedAt, + required this.version, + }); + + factory HealthEvent.fromJson(Map json) { + return HealthEvent( + id: json['id'] as String, + petId: json['petId'] as String, + eventType: HealthEventType.fromJson(json['eventType'] as String), + occurredAt: DateTime.parse(json['occurredAt'] as String), + title: json['title'] as String, + notes: json['notes'] as String?, + amountCents: json['amountCents'] as int?, + createdByUserId: json['createdByUserId'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + version: json['version'] as int, + ); + } + + final String id; + final String petId; + final HealthEventType eventType; + final DateTime occurredAt; + final String title; + final String? notes; + + /// 金额(整数分,非负,可空)。DTO 层保持整数分不换算。 + final int? amountCents; + final String createdByUserId; + final DateTime createdAt; + final DateTime updatedAt; + final int version; +} + +/// 添加健康事件请求(createdByUserId 取自 token,不收请求体)。 +class CreateHealthEventRequest { + const CreateHealthEventRequest({ + required this.eventType, + required this.occurredAt, + required this.title, + this.notes, + this.amountCents, + }); + + final HealthEventType eventType; + final DateTime occurredAt; + final String title; + final String? notes; + + /// 整数分;提交小数服务端 400/40000(不做静默截断)。 + final int? amountCents; + + Map toJson() => { + 'eventType': eventType.name, + 'occurredAt': occurredAt.toIso8601String(), + 'title': title, + if (notes != null) 'notes': notes, + if (amountCents != null) 'amountCents': amountCents, + }; +} + +/// 更新健康事件请求(仅 title / notes / amountCents 可编辑;version 必填)。 +class UpdateHealthEventRequest { + const UpdateHealthEventRequest({ + required this.version, + this.title, + this.notes, + this.amountCents, + }); + + final int version; + final String? title; + final String? notes; + final int? amountCents; + + Map toJson() => { + 'version': version, + if (title != null) 'title': title, + if (notes != null) 'notes': notes, + if (amountCents != null) 'amountCents': amountCents, + }; +} + +/// 照护提醒(无 version 字段;completedAt 非空当且仅当 status=completed)。 +class CareReminder { + const CareReminder({ + required this.id, + required this.petId, + required this.reminderType, + required this.title, + required this.dueAt, + required this.status, + required this.completedAt, + required this.createdAt, + required this.updatedAt, + }); + + factory CareReminder.fromJson(Map json) { + return CareReminder( + id: json['id'] as String, + petId: json['petId'] as String, + reminderType: CareReminderType.fromJson(json['reminderType'] as String), + title: json['title'] as String, + dueAt: DateTime.parse(json['dueAt'] as String), + status: CareReminderStatus.fromJson(json['status'] as String), + completedAt: json['completedAt'] == null + ? null + : DateTime.parse(json['completedAt'] as String), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + ); + } + + final String id; + final String petId; + final CareReminderType reminderType; + final String title; + final DateTime dueAt; + final CareReminderStatus status; + final DateTime? completedAt; + final DateTime createdAt; + final DateTime updatedAt; +} + +/// 创建照护提醒请求(创建恒为 pending,不收 status)。 +class CreateCareReminderRequest { + const CreateCareReminderRequest({ + required this.reminderType, + required this.title, + required this.dueAt, + }); + + final CareReminderType reminderType; + final String title; + final DateTime dueAt; + + Map toJson() => { + 'reminderType': reminderType.name, + 'title': title, + 'dueAt': dueAt.toIso8601String(), + }; +} + +/// 更新提醒状态请求(状态流转专用:仅 status + completedAt)。 +class UpdateCareReminderRequest { + const UpdateCareReminderRequest({required this.status, this.completedAt}); + + final CareReminderStatus status; + + /// status=completed 时必填;其余状态禁带(违反 422/42202)。 + /// 由客户端提交(而非服务端 now()),允许补记实际完成时刻。 + final DateTime? completedAt; + + Map toJson() => { + 'status': status.name, + if (completedAt != null) 'completedAt': completedAt!.toIso8601String(), + }; +} + +/// 摘要:最新体重(无记录 → 整体 null)。 +class SummaryLatestWeight { + const SummaryLatestWeight({required this.weightKg, required this.measuredAt}); + + factory SummaryLatestWeight.fromJson(Map json) { + return SummaryLatestWeight( + weightKg: (json['weightKg'] as num).toDouble(), + measuredAt: DateTime.parse(json['measuredAt'] as String), + ); + } + + final double weightKg; + final DateTime measuredAt; +} + +/// 摘要:疫苗进度(totalDoses=0 → 整体 null,不是 0/0)。 +class SummaryVaccinationProgress { + const SummaryVaccinationProgress({ + required this.completedDoses, + required this.totalDoses, + }); + + factory SummaryVaccinationProgress.fromJson(Map json) { + return SummaryVaccinationProgress( + completedDoses: json['completedDoses'] as int, + totalDoses: json['totalDoses'] as int, + ); + } + + final int completedDoses; + final int totalDoses; +} + +/// 摘要:下次接种(候选集空 → 整体 null;dueOn 可为过去日期——逾期针仍是下一针)。 +class SummaryNextVaccination { + const SummaryNextVaccination({ + required this.vaccinationId, + required this.vaccineId, + required this.vaccineName, + required this.doseNo, + required this.doseLabel, + required this.dueOn, + required this.source, + }); + + factory SummaryNextVaccination.fromJson(Map json) { + return SummaryNextVaccination( + vaccinationId: json['vaccinationId'] as String, + vaccineId: json['vaccineId'] as String, + vaccineName: json['vaccineName'] as String, + doseNo: json['doseNo'] as int, + doseLabel: json['doseLabel'] as String?, + dueOn: DateTime.parse(json['dueOn'] as String), + source: NextVaccinationSource.fromJson(json['source'] as String), + ); + } + + final String vaccinationId; + final String vaccineId; + final String vaccineName; + final int doseNo; + final String? doseLabel; + final DateTime dueOn; + final NextVaccinationSource source; +} + +/// 摘要:当月花费(恒非 null;无支出 amountCents=0;月边界随 tz 移动)。 +class SummaryMonthlyExpense { + const SummaryMonthlyExpense({ + required this.month, + required this.timezone, + required this.amountCents, + }); + + factory SummaryMonthlyExpense.fromJson(Map json) { + return SummaryMonthlyExpense( + month: json['month'] as String, + timezone: json['timezone'] as String, + amountCents: json['amountCents'] as int, + ); + } + + /// ISO year-month(如 2026-09)。 + final String month; + final String timezone; + final int amountCents; +} + +/// 档案聚合摘要(四项聚合实时计算;前三项无记录时为 null, +/// monthlyExpense 恒非 null——契约 PetSummary null 语义)。 +class PetSummary { + const PetSummary({ + required this.petId, + required this.latestWeight, + required this.vaccinationProgress, + required this.nextVaccination, + required this.monthlyExpense, + }); + + factory PetSummary.fromJson(Map json) { + return PetSummary( + petId: json['petId'] as String, + latestWeight: json['latestWeight'] == null + ? null + : SummaryLatestWeight.fromJson( + json['latestWeight'] as Map, + ), + vaccinationProgress: json['vaccinationProgress'] == null + ? null + : SummaryVaccinationProgress.fromJson( + json['vaccinationProgress'] as Map, + ), + nextVaccination: json['nextVaccination'] == null + ? null + : SummaryNextVaccination.fromJson( + json['nextVaccination'] as Map, + ), + monthlyExpense: SummaryMonthlyExpense.fromJson( + json['monthlyExpense'] as Map, + ), + ); + } + + final String petId; + final SummaryLatestWeight? latestWeight; + final SummaryVaccinationProgress? vaccinationProgress; + final SummaryNextVaccination? nextVaccination; + final SummaryMonthlyExpense monthlyExpense; +} diff --git a/lib/features/pets/pets_controller.dart b/lib/features/pets/pets_controller.dart new file mode 100644 index 0000000..98a4e0f --- /dev/null +++ b/lib/features/pets/pets_controller.dart @@ -0,0 +1,96 @@ +import 'package:flutter/foundation.dart'; +import 'package:patbond_flutter/core/network/api_exception.dart'; +import 'package:patbond_flutter/features/pets/pet_models.dart'; +import 'package:patbond_flutter/features/pets/pets_repository.dart'; + +/// 网络数据四态(第 9 节硬要求:loading / empty / error / retry, +/// data 态含空列表判定,页面据 [PetsLoadPhase] + 数据渲染四态)。 +enum PetsLoadPhase { initial, loading, ready, error } + +/// pets feature 状态控制器(开发计划 §4.2 分层: +/// Page/Widget → PetsController → PetsRepository → ApiClient)。 +/// +/// 宠物档案状态自此从 `AppState` 拆出:本 feature 不读写 AppState 的 +/// 宠物/疫苗 demo 数据;服务端是唯一事实来源,内存副本仅作展示缓存 +/// (无本地持久化——档案数据以 [refresh] 为失效/刷新策略)。 +/// +/// 本单只承载列表 + 详情主链路状态;体重/疫苗/事件/提醒的页面级 +/// 状态由 T2-12~14 按页面直接经 Repository 取数或扩展本控制器。 +class PetsController extends ChangeNotifier { + PetsController({required this._repository}); + + final PetsRepository _repository; + + PetsLoadPhase _phase = PetsLoadPhase.initial; + List _pets = const []; + ApiException? _lastError; + bool _disposed = false; + + PetsLoadPhase get phase => _phase; + + /// 当前用户可见宠物(服务端 created_at DESC 排序原样保留)。 + List get pets => _pets; + + /// ready 且列表为空 → 空态(新用户建档引导)。 + bool get isEmpty => _phase == PetsLoadPhase.ready && _pets.isEmpty; + + /// 最近一次加载失败的类型化错误(error 态时非 null)。 + ApiException? get lastError => _lastError; + + /// 加载 / 重试宠物列表。错误不外抛,收敛为 error 态供页面渲染 + retry。 + Future refresh() async { + _phase = PetsLoadPhase.loading; + _lastError = null; + _notify(); + try { + _pets = await _repository.listPets(); + _phase = PetsLoadPhase.ready; + } on ApiException catch (error) { + _lastError = error; + _phase = PetsLoadPhase.error; + } + _notify(); + } + + /// 创建宠物:成功后就地插入列表头(服务端 created_at DESC,新建最前), + /// 失败按类型化异常外抛给表单层处理(40903 芯片号冲突等)。 + Future createPet(CreatePetRequest request) async { + final pet = await _repository.createPet(request); + _pets = [pet, ..._pets]; + if (_phase != PetsLoadPhase.ready) _phase = PetsLoadPhase.ready; + _notify(); + return pet; + } + + /// 拉取单只宠物详情并同步列表内存副本。 + Future getPet(String petId) async { + final pet = await _repository.getPet(petId); + _replaceInList(pet); + return pet; + } + + /// 更新宠物档案:成功后同步列表副本;40902 版本冲突等外抛, + /// 页面提示后应调用 [getPet] 刷新取新 version 重提。 + Future updatePet(String petId, UpdatePetRequest request) async { + final pet = await _repository.updatePet(petId, request); + _replaceInList(pet); + return pet; + } + + void _replaceInList(Pet pet) { + final index = _pets.indexWhere((item) => item.id == pet.id); + if (index == -1) return; + _pets = [..._pets]..[index] = pet; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/features/pets/pets_repository.dart b/lib/features/pets/pets_repository.dart new file mode 100644 index 0000000..40adee1 --- /dev/null +++ b/lib/features/pets/pets_repository.dart @@ -0,0 +1,312 @@ +import 'package:patbond_flutter/core/network/api_client.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:uuid/uuid.dart'; + +/// pets 域仓库接口(契约 v1.2.0 的 12 路径 / 18 操作全覆盖; +/// 页面依赖此抽象,widget 测试注入假实现)。 +abstract class PetsRepository { + // ---- 宠物 CRUD ---- + Future> listPets(); + Future createPet(CreatePetRequest request); + Future getPet(String petId); + Future updatePet(String petId, UpdatePetRequest request); + + // ---- 字典 ---- + Future> listBreeds({PetSpecies? species}); + Future> listVaccineCatalog({PetSpecies? species}); + + // ---- 体重(cursor 分页)---- + Future> listWeights( + String petId, { + int? limit, + String? cursor, + }); + Future createWeight(String petId, CreateWeightRequest request); + + // ---- 疫苗 ---- + Future> listVaccinations(String petId); + Future createVaccination( + String petId, + CreateVaccinationRequest request, + ); + Future updateVaccination( + String vaccinationId, + UpdateVaccinationRequest request, + ); + + // ---- 健康事件(cursor 分页)---- + Future> listHealthEvents( + String petId, { + int? limit, + String? cursor, + }); + Future createHealthEvent( + String petId, + CreateHealthEventRequest request, + ); + Future updateHealthEvent( + String eventId, + UpdateHealthEventRequest request, + ); + + // ---- 照护提醒 ---- + Future> listCareReminders( + String petId, { + CareReminderStatus? status, + }); + Future createCareReminder( + String petId, + CreateCareReminderRequest request, + ); + Future updateCareReminder( + String reminderId, + UpdateCareReminderRequest request, + ); + + // ---- 聚合摘要 ---- + Future getPetSummary(String petId, {String? tz}); +} + +/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token +/// 拦截 + 401/40101 单飞刷新重放);pets 域业务错误码升格为类型化异常。 +/// +/// 幂等:weights / vaccinations / health-events / care-reminders 四个 POST +/// 按契约携带可选 `Idempotency-Key`(每次逻辑提交换新键;token 刷新后的 +/// 自动重放沿用同一个键,与 auth register 先例一致)。 +class ApiPetsRepository implements PetsRepository { + ApiPetsRepository({required this._api, this._uuid = const Uuid()}); + + final ApiClient _api; + final Uuid _uuid; + + Future _request( + String path, { + String method = 'GET', + Object? body, + Map? query, + bool idempotent = false, + }) async { + try { + return await _api.request( + path, + method: method, + body: body, + query: query, + headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null, + requiresAuth: true, + ); + } on ApiBusinessException catch (error) { + throw mapPetBusinessException(error); + } + } + + Map _asMap(Object? data) => data! as Map; + + List _asList(Object? data, T Function(Map) fromJson) { + return (data! as List) + .map((item) => fromJson(item as Map)) + .toList(); + } + + @override + Future> listPets() async { + final data = await _request('/api/v1/pets'); + return _asList(data, Pet.fromJson); + } + + @override + Future createPet(CreatePetRequest request) async { + final data = await _request( + '/api/v1/pets', + method: 'POST', + body: request.toJson(), + ); + return Pet.fromJson(_asMap(data)); + } + + @override + Future getPet(String petId) async { + final data = await _request('/api/v1/pets/$petId'); + return Pet.fromJson(_asMap(data)); + } + + @override + Future updatePet(String petId, UpdatePetRequest request) async { + final data = await _request( + '/api/v1/pets/$petId', + method: 'PATCH', + body: request.toJson(), + ); + return Pet.fromJson(_asMap(data)); + } + + @override + Future> listBreeds({PetSpecies? species}) async { + final data = await _request( + '/api/v1/breeds', + query: {if (species != null) 'species': species.name}, + ); + return _asList(data, Breed.fromJson); + } + + @override + Future> listVaccineCatalog({ + PetSpecies? species, + }) async { + final data = await _request( + '/api/v1/vaccine-catalog', + query: {if (species != null) 'species': species.name}, + ); + return _asList(data, VaccineCatalogItem.fromJson); + } + + @override + Future> listWeights( + String petId, { + int? limit, + String? cursor, + }) async { + final data = await _request( + '/api/v1/pets/$petId/weights', + query: {'limit': ?limit, 'cursor': ?cursor}, + ); + return CursorPage.fromJson(_asMap(data), WeightRecord.fromJson); + } + + @override + Future createWeight( + String petId, + CreateWeightRequest request, + ) async { + final data = await _request( + '/api/v1/pets/$petId/weights', + method: 'POST', + body: request.toJson(), + idempotent: true, + ); + return WeightRecord.fromJson(_asMap(data)); + } + + @override + Future> listVaccinations(String petId) async { + final data = await _request('/api/v1/pets/$petId/vaccinations'); + return _asList(data, Vaccination.fromJson); + } + + @override + Future createVaccination( + String petId, + CreateVaccinationRequest request, + ) async { + final data = await _request( + '/api/v1/pets/$petId/vaccinations', + method: 'POST', + body: request.toJson(), + idempotent: true, + ); + return Vaccination.fromJson(_asMap(data)); + } + + @override + Future updateVaccination( + String vaccinationId, + UpdateVaccinationRequest request, + ) async { + final data = await _request( + '/api/v1/vaccinations/$vaccinationId', + method: 'PATCH', + body: request.toJson(), + ); + return Vaccination.fromJson(_asMap(data)); + } + + @override + Future> listHealthEvents( + String petId, { + int? limit, + String? cursor, + }) async { + final data = await _request( + '/api/v1/pets/$petId/health-events', + query: {'limit': ?limit, 'cursor': ?cursor}, + ); + return CursorPage.fromJson(_asMap(data), HealthEvent.fromJson); + } + + @override + Future createHealthEvent( + String petId, + CreateHealthEventRequest request, + ) async { + final data = await _request( + '/api/v1/pets/$petId/health-events', + method: 'POST', + body: request.toJson(), + idempotent: true, + ); + return HealthEvent.fromJson(_asMap(data)); + } + + @override + Future updateHealthEvent( + String eventId, + UpdateHealthEventRequest request, + ) async { + final data = await _request( + '/api/v1/health-events/$eventId', + method: 'PATCH', + body: request.toJson(), + ); + return HealthEvent.fromJson(_asMap(data)); + } + + @override + Future> listCareReminders( + String petId, { + CareReminderStatus? status, + }) async { + final data = await _request( + '/api/v1/pets/$petId/care-reminders', + query: {if (status != null) 'status': status.name}, + ); + return _asList(data, CareReminder.fromJson); + } + + @override + Future createCareReminder( + String petId, + CreateCareReminderRequest request, + ) async { + final data = await _request( + '/api/v1/pets/$petId/care-reminders', + method: 'POST', + body: request.toJson(), + idempotent: true, + ); + return CareReminder.fromJson(_asMap(data)); + } + + @override + Future updateCareReminder( + String reminderId, + UpdateCareReminderRequest request, + ) async { + final data = await _request( + '/api/v1/care-reminders/$reminderId', + method: 'PATCH', + body: request.toJson(), + ); + return CareReminder.fromJson(_asMap(data)); + } + + @override + Future getPetSummary(String petId, {String? tz}) async { + final data = await _request( + '/api/v1/pets/$petId/summary', + query: {'tz': ?tz}, + ); + return PetSummary.fromJson(_asMap(data)); + } +} diff --git a/test/features/pets/money_test.dart b/test/features/pets/money_test.dart new file mode 100644 index 0000000..5c7e01f --- /dev/null +++ b/test/features/pets/money_test.dart @@ -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); + } + }); + }); +} diff --git a/test/features/pets/pet_models_test.dart b/test/features/pets/pet_models_test.dart new file mode 100644 index 0000000..657a7f7 --- /dev/null +++ b/test/features/pets/pet_models_test.dart @@ -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 petJson({Map 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 vaccinationJson({ + Map 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': [], + '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, '首针'); + }); + }); +} diff --git a/test/features/pets/pets_controller_test.dart b/test/features/pets/pets_controller_test.dart new file mode 100644 index 0000000..39af165 --- /dev/null +++ b/test/features/pets/pets_controller_test.dart @@ -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> Function()? listPetsHandler; + Future Function(CreatePetRequest)? createPetHandler; + Future Function(String)? getPetHandler; + Future Function(String, UpdatePetRequest)? updatePetHandler; + + @override + Future> listPets() => + listPetsHandler?.call() ?? Future.value(const []); + + @override + Future createPet(CreatePetRequest request) => createPetHandler!(request); + + @override + Future getPet(String petId) => getPetHandler!(petId); + + @override + Future 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 = []; + 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()); + + 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()), + ); + 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()), + ); + }); + + 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); + }); +} diff --git a/test/features/pets/pets_repository_test.dart b/test/features/pets/pets_repository_test.dart new file mode 100644 index 0000000..801ba0b --- /dev/null +++ b/test/features/pets/pets_repository_test.dart @@ -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 setUpWith( + Future 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('listPets:GET /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('createPet:POST 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:路径带 petId,PATCH 提交 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('listWeights:cursor 分页参数与信封解析', () 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-Key(weights/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': [], '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('getPetSummary:tz 参数传递与四聚合解析', () 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 expectMapped( + int httpStatus, + int code, + TypeMatcher 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()); + }); + + test('40401 → PetNotFoundException(防枚举 404)', () async { + await expectMapped(404, 40401, isA()); + }); + + test('40402 → PetRecordNotFoundException(记录级防枚举)', () async { + await expectMapped(404, 40402, isA()); + }); + + test('40902 → PetVersionConflictException', () async { + await expectMapped(409, 40902, isA()); + }); + + test('40903 → MicrochipTakenException', () async { + await expectMapped(409, 40903, isA()); + }); + + test('40904 → VaccinationDoseExistsException', () async { + await expectMapped(409, 40904, isA()); + }); + + test('42201 → VaccinationRuleException', () async { + await expectMapped(422, 42201, isA()); + }); + + test('42202 → CareReminderRuleException', () async { + await expectMapped(422, 42202, isA()); + }); + + test('未覆盖码(40000)保持通用 ApiBusinessException', () async { + await setUpWith( + (options) async => jsonResponse(400, errorEnvelope(40000, '参数校验失败')), + ); + + await expectLater( + repository.getPet('p-1'), + throwsA( + isA() + .having((e) => e.code, 'code', 40000) + .having( + (e) => e, + 'runtimeType', + isNot(isA()), + ), + ), + ); + }); + + test('类型化异常仍可按基类 ApiBusinessException 捕获', () { + const error = PetVersionConflictException(message: '数据已被修改'); + expect(error, isA()); + 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'); + }); +} diff --git a/test/helpers/pet_test_helpers.dart b/test/helpers/pet_test_helpers.dart new file mode 100644 index 0000000..d1e702d --- /dev/null +++ b/test/helpers/pet_test_helpers.dart @@ -0,0 +1,107 @@ +/// pets 域测试样本(契约 openapi.yaml v1.2.0 各 schema 全字段 JSON)。 +library; + +Map okListEnvelope(List data) => { + 'code': 0, + 'message': 'ok', + 'data': data, +}; + +Map samplePetJson({ + Map overrides = const {}, +}) => { + 'id': 'p-1', + 'name': '豆豆', + 'species': 'dog', + 'breedId': 'b-1', + 'breedDisplayName': '柴犬', + 'customBreedName': null, + 'sex': 'male', + 'birthDate': '2024-03-15', + 'birthDateEstimated': false, + 'personality': null, + 'microchipNo': null, + 'sterilizedOn': null, + 'status': 'active', + 'myRole': 'owner', + 'createdAt': '2026-09-01T10:00:00+08:00', + 'updatedAt': '2026-09-02T10:00:00+08:00', + 'version': 3, + ...overrides, +}; + +Map sampleWeightJson() => { + 'id': 'w-1', + 'petId': 'p-1', + 'weightKg': 4.35, + 'measuredAt': '2026-09-07T09:00:00+08:00', + 'source': 'manual', + 'note': null, + 'createdAt': '2026-09-07T09:01:00+08:00', +}; + +Map sampleVaccinationJson() => { + 'id': 'vx-1', + 'petId': 'p-1', + 'vaccineId': 'v-1', + 'vaccineName': '狂犬疫苗', + 'seriesKey': 'rabies-initial', + 'doseNo': 1, + 'doseLabel': null, + 'status': 'scheduled', + 'plannedOn': '2026-10-01', + 'administeredOn': null, + 'nextDueOn': null, + 'manufacturer': null, + 'batchNo': null, + 'notes': null, + 'createdAt': '2026-09-01T10:00:00+08:00', + 'updatedAt': '2026-09-01T10:00:00+08:00', + 'version': 1, +}; + +Map sampleHealthEventJson() => { + 'id': 'e-1', + 'petId': 'p-1', + 'eventType': 'medical', + 'occurredAt': '2026-09-05T14:00:00+08:00', + 'title': '皮肤检查', + 'notes': null, + 'amountCents': 12850, + 'createdByUserId': 'u-1', + 'createdAt': '2026-09-05T14:05:00+08:00', + 'updatedAt': '2026-09-05T14:05:00+08:00', + 'version': 1, +}; + +Map sampleCareReminderJson() => { + 'id': 'r-1', + 'petId': 'p-1', + 'reminderType': 'checkup', + 'title': '年度体检', + 'dueAt': '2026-10-01T00:00:00+08:00', + 'status': 'pending', + 'completedAt': null, + 'createdAt': '2026-09-01T10:00:00+08:00', + 'updatedAt': '2026-09-01T10:00:00+08:00', +}; + +Map samplePetSummaryJson() => { + '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, + }, +};