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>
388 lines
13 KiB
Dart
388 lines
13 KiB
Dart
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('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': <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('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<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');
|
||
});
|
||
}
|