Files
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

388 lines
13 KiB
Dart
Raw Permalink 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:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/features/auth/session_manager.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/pet_test_helpers.dart';
void main() {
late SessionManager session;
late FakeHttpAdapter adapter;
late ApiPetsRepository repository;
Future<void> setUpWith(
Future<ResponseBody> Function(RequestOptions) handler,
) async {
session = SessionManager(store: InMemoryTokenStore());
await session.updateTokens(sampleTokens(access: 'pet-access'));
final dio = buildPatbondDio(session: session, baseUrl: 'http://pet.local');
adapter = FakeHttpAdapter(handler);
dio.httpClientAdapter = adapter;
final refresher = TokenRefresher(dio: dio, session: session);
repository = ApiPetsRepository(
api: ApiClient(dio: dio, session: session, refresher: refresher),
);
}
group('请求线路(路径 / 方法 / 鉴权 / 参数)', () {
test('listPetsGET /api/v1/pets,携带 Bearer', () async {
await setUpWith(
(options) async => jsonResponse(200, okListEnvelope([samplePetJson()])),
);
final pets = await repository.listPets();
final request = adapter.requests.single;
expect(request.path, '/api/v1/pets');
expect(request.method, 'GET');
expect(request.headers['Authorization'], 'Bearer pet-access');
expect(pets.single.name, '豆豆');
expect(pets.single.myRole, PetRole.owner);
});
test('createPetPOST 201,不带 Idempotency-Key(契约:唯一约束兜底)', () async {
await setUpWith(
(options) async => jsonResponse(201, okEnvelope(samplePetJson())),
);
final pet = await repository.createPet(
const CreatePetRequest(
name: '豆豆',
species: PetSpecies.dog,
sex: PetSex.male,
breedId: 'b-1',
),
);
final request = adapter.requests.single;
expect(request.method, 'POST');
expect(request.data, {
'name': '豆豆',
'species': 'dog',
'sex': 'male',
'breedId': 'b-1',
});
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
expect(pet.id, isNotEmpty);
});
test('getPet / updatePet:路径带 petIdPATCH 提交 version', () async {
await setUpWith(
(options) async => jsonResponse(200, okEnvelope(samplePetJson())),
);
await repository.getPet('p-1');
await repository.updatePet(
'p-1',
const UpdatePetRequest(version: 3, name: '豆豆二世'),
);
expect(adapter.requests[0].path, '/api/v1/pets/p-1');
expect(adapter.requests[0].method, 'GET');
expect(adapter.requests[1].method, 'PATCH');
expect(adapter.requests[1].data, {'version': 3, 'name': '豆豆二世'});
});
test('字典:species 过滤参数按枚举名传递,缺省不传', () async {
await setUpWith((options) async => jsonResponse(200, okListEnvelope([])));
await repository.listBreeds(species: PetSpecies.cat);
await repository.listBreeds();
await repository.listVaccineCatalog(species: PetSpecies.dog);
expect(adapter.requests[0].path, '/api/v1/breeds');
expect(adapter.requests[0].queryParameters, {'species': 'cat'});
expect(adapter.requests[1].queryParameters, isEmpty);
expect(adapter.requests[2].path, '/api/v1/vaccine-catalog');
expect(adapter.requests[2].queryParameters, {'species': 'dog'});
});
test('listWeightscursor 分页参数与信封解析', () async {
await setUpWith(
(options) async => jsonResponse(
200,
okEnvelope({
'items': [sampleWeightJson()],
'nextCursor': 'c2',
'hasMore': true,
}),
),
);
final page = await repository.listWeights('p-1', limit: 50, cursor: 'c1');
final request = adapter.requests.single;
expect(request.path, '/api/v1/pets/p-1/weights');
expect(request.queryParameters, {'limit': 50, 'cursor': 'c1'});
expect(page.items.single.weightKg, 4.35);
expect(page.nextCursor, 'c2');
expect(page.hasMore, isTrue);
});
test(
'四个 POST 携带 Idempotency-Keyweights/vaccinations/health-events/care-reminders',
() async {
var call = 0;
await setUpWith((options) async {
call += 1;
return switch (call) {
1 => jsonResponse(201, okEnvelope(sampleWeightJson())),
2 => jsonResponse(201, okEnvelope(sampleVaccinationJson())),
3 => jsonResponse(201, okEnvelope(sampleHealthEventJson())),
_ => jsonResponse(201, okEnvelope(sampleCareReminderJson())),
};
});
await repository.createWeight(
'p-1',
CreateWeightRequest(
weightKg: 4.35,
measuredAt: DateTime.utc(2026, 9, 7),
),
);
await repository.createVaccination(
'p-1',
const CreateVaccinationRequest(
vaccineId: 'v-1',
seriesKey: 'rabies-initial',
doseNo: 1,
status: VaccinationStatus.scheduled,
),
);
await repository.createHealthEvent(
'p-1',
CreateHealthEventRequest(
eventType: HealthEventType.note,
occurredAt: DateTime.utc(2026, 9, 7),
title: '记录',
),
);
await repository.createCareReminder(
'p-1',
CreateCareReminderRequest(
reminderType: CareReminderType.checkup,
title: '体检',
dueAt: DateTime.utc(2026, 10, 1),
),
);
final keys = adapter.requests
.map((r) => r.headers['Idempotency-Key'] as String?)
.toList();
expect(keys, everyElement(isNotNull));
expect(keys, everyElement(isNotEmpty));
// 每次逻辑提交换新键
expect(keys.toSet().length, 4);
},
);
test('疫苗 / 事件 / 提醒 PATCH:顶层短路径(不含 petId', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return switch (call) {
1 => jsonResponse(200, okEnvelope(sampleVaccinationJson())),
2 => jsonResponse(200, okEnvelope(sampleHealthEventJson())),
_ => jsonResponse(200, okEnvelope(sampleCareReminderJson())),
};
});
await repository.updateVaccination(
'vx-1',
const UpdateVaccinationRequest(
version: 1,
status: VaccinationStatus.cancelled,
),
);
await repository.updateHealthEvent(
'e-1',
const UpdateHealthEventRequest(version: 1, title: '复查'),
);
await repository.updateCareReminder(
'r-1',
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
);
expect(adapter.requests[0].path, '/api/v1/vaccinations/vx-1');
expect(adapter.requests[1].path, '/api/v1/health-events/e-1');
expect(adapter.requests[2].path, '/api/v1/care-reminders/r-1');
expect(adapter.requests.map((r) => r.method), everyElement('PATCH'));
});
test('listVaccinations / listCareReminders:不分页数组 + status 过滤', () async {
var call = 0;
await setUpWith((options) async {
call += 1;
return call == 1
? jsonResponse(200, okListEnvelope([sampleVaccinationJson()]))
: jsonResponse(200, okListEnvelope([sampleCareReminderJson()]));
});
final vaccinations = await repository.listVaccinations('p-1');
final reminders = await repository.listCareReminders(
'p-1',
status: CareReminderStatus.pending,
);
expect(adapter.requests[0].path, '/api/v1/pets/p-1/vaccinations');
expect(adapter.requests[0].queryParameters, isEmpty);
expect(adapter.requests[1].path, '/api/v1/pets/p-1/care-reminders');
expect(adapter.requests[1].queryParameters, {'status': 'pending'});
expect(vaccinations.single.vaccineName, '狂犬疫苗');
expect(reminders.single.status, CareReminderStatus.pending);
});
test('listHealthEvents:分页参数缺省不传', () async {
await setUpWith(
(options) async => jsonResponse(
200,
okEnvelope({'items': <Object?>[], 'hasMore': false}),
),
);
final page = await repository.listHealthEvents('p-1');
expect(adapter.requests.single.path, '/api/v1/pets/p-1/health-events');
expect(adapter.requests.single.queryParameters, isEmpty);
expect(page.items, isEmpty);
expect(page.hasMore, isFalse);
});
test('getPetSummarytz 参数传递与四聚合解析', () async {
await setUpWith(
(options) async =>
jsonResponse(200, okEnvelope(samplePetSummaryJson())),
);
final summary = await repository.getPetSummary(
'p-1',
tz: 'Asia/Shanghai',
);
final request = adapter.requests.single;
expect(request.path, '/api/v1/pets/p-1/summary');
expect(request.queryParameters, {'tz': 'Asia/Shanghai'});
expect(summary.monthlyExpense.timezone, 'Asia/Shanghai');
expect(summary.latestWeight!.weightKg, 4.35);
});
});
group('错误映射(新 8 码 → 类型化异常)', () {
Future<void> expectMapped(
int httpStatus,
int code,
TypeMatcher<ApiBusinessException> matcher,
) async {
await setUpWith(
(options) async => jsonResponse(httpStatus, errorEnvelope(code, 'err')),
);
await expectLater(
repository.getPet('p-1'),
throwsA(matcher.having((e) => e.code, 'code', code)),
);
}
test('40300 → PetAccessDeniedException', () async {
await expectMapped(403, 40300, isA<PetAccessDeniedException>());
});
test('40401 → PetNotFoundException(防枚举 404', () async {
await expectMapped(404, 40401, isA<PetNotFoundException>());
});
test('40402 → PetRecordNotFoundException(记录级防枚举)', () async {
await expectMapped(404, 40402, isA<PetRecordNotFoundException>());
});
test('40902 → PetVersionConflictException', () async {
await expectMapped(409, 40902, isA<PetVersionConflictException>());
});
test('40903 → MicrochipTakenException', () async {
await expectMapped(409, 40903, isA<MicrochipTakenException>());
});
test('40904 → VaccinationDoseExistsException', () async {
await expectMapped(409, 40904, isA<VaccinationDoseExistsException>());
});
test('42201 → VaccinationRuleException', () async {
await expectMapped(422, 42201, isA<VaccinationRuleException>());
});
test('42202 → CareReminderRuleException', () async {
await expectMapped(422, 42202, isA<CareReminderRuleException>());
});
test('未覆盖码(40000)保持通用 ApiBusinessException', () async {
await setUpWith(
(options) async => jsonResponse(400, errorEnvelope(40000, '参数校验失败')),
);
await expectLater(
repository.getPet('p-1'),
throwsA(
isA<ApiBusinessException>()
.having((e) => e.code, 'code', 40000)
.having(
(e) => e,
'runtimeType',
isNot(isA<PetNotFoundException>()),
),
),
);
});
test('类型化异常仍可按基类 ApiBusinessException 捕获', () {
const error = PetVersionConflictException(message: '数据已被修改');
expect(error, isA<ApiBusinessException>());
expect(error.code, ApiCodes.versionConflict);
});
});
group('token 拦截复用', () {
test('40101:单飞刷新后重放,重放沿用同一 Idempotency-Key', () async {
await setUpWith((options) async {
if (options.path == '/api/v1/auth/refresh') {
return jsonResponse(
200,
okEnvelope(tokenDataJson(access: 'new-access')),
);
}
if (options.headers['Authorization'] == 'Bearer pet-access') {
return jsonResponse(401, errorEnvelope(40101, 'token 过期'));
}
return jsonResponse(201, okEnvelope(sampleWeightJson()));
});
await repository.createWeight(
'p-1',
CreateWeightRequest(
weightKg: 4.35,
measuredAt: DateTime.utc(2026, 9, 7),
),
);
final weightRequests = adapter.requests
.where((r) => r.path == '/api/v1/pets/p-1/weights')
.toList();
expect(weightRequests, hasLength(2));
expect(weightRequests.last.headers['Authorization'], 'Bearer new-access');
expect(
weightRequests.first.headers['Idempotency-Key'],
weightRequests.last.headers['Idempotency-Key'],
);
});
});
test('pet 服务基地址常量存在且默认指向 :8083', () {
expect(patbondPetApiBaseUrl, 'http://127.0.0.1:8083');
});
}