720865bcb9
CI / flutter-gates (push) Successful in 2m12s
11 场景对 compose 真实后端全链路取证:注册登录、建档(含品种)、 体重×2 分页读回、疫苗 scheduled→completed(乐观锁)、健康事件 (整数分金额)、提醒完成流转、摘要四项聚合逐项断言、第二账号四路 404/40401 防枚举核对、跨设备全量读回、v2 埋点 202 逐条 accepted、 并发 PATCH 同 version 409/40902。放根目录与 test_e2e_manual.dart 并列,不入 flutter test。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
778 lines
27 KiB
Dart
778 lines
27 KiB
Dart
#!/usr/bin/env dart
|
||
|
||
// ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。
|
||
/// M2 E2E 烟囱测试脚本(T2-18 收官):对 compose 真实后端跑通 M2 完整链路。
|
||
///
|
||
/// 前置条件:patbond-api 目录执行 `docker compose up -d`
|
||
/// 运行方式:dart run test_e2e_m2_manual.dart
|
||
///
|
||
/// 覆盖 11 个场景(工单 T2-18 定义的链路):
|
||
/// 1. 注册账号 A → 登录
|
||
/// 2. 建档(POST /pets,含品种)→ 列表/详情读回核对
|
||
/// 3. 记体重 ×2 → 列表分页读回(cursor 分页两页取齐)
|
||
/// 4. 登记疫苗(scheduled)→ 标记完成(PATCH,version 乐观锁)
|
||
/// 5. 记健康事件(金额整数分)→ 时间线读回
|
||
/// 6. 创建提醒 → 标记完成(completedAt 校验)
|
||
/// 7. 摘要核对:最新体重 / 疫苗进度 / 下次接种 / 当月花费逐项断言
|
||
/// 8. 权限拒绝:账号 B 访问 A 的宠物四路 → 全部 404/40401 且响应体一致(防枚举)
|
||
/// 9. 跨设备读取:账号 A 重新登录(新会话)→ 全量数据读回核对
|
||
/// 10. 埋点链路:POST /api/v1/events 上报 v2 事件 → 202 逐条 accepted
|
||
/// 11. 乐观锁冲突:两次 PATCH 同一 version → 第二次 409/40902
|
||
///
|
||
/// 真机专属项(Android 事件落库观察、SessionTracker 30min 手测)按方案 A 挂起,
|
||
/// 不在本脚本范围内。
|
||
library;
|
||
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
|
||
const authUrl = 'http://127.0.0.1:8081'; // patbond-auth
|
||
const userUrl = 'http://127.0.0.1:8082'; // patbond-user(/me、/events)
|
||
const petUrl = 'http://127.0.0.1:8083'; // patbond-pet(pets 域 12 路径)
|
||
|
||
final client = HttpClient();
|
||
int _passed = 0;
|
||
|
||
String redact(String token) =>
|
||
'${token.substring(0, min(20, token.length))}...<REDACTED>';
|
||
|
||
void fail(String msg) {
|
||
print(' ✗ $msg');
|
||
client.close();
|
||
exit(1);
|
||
}
|
||
|
||
void check(bool cond, String okMsg, String failMsg) {
|
||
if (cond) {
|
||
print(' ✓ $okMsg');
|
||
} else {
|
||
fail(failMsg);
|
||
}
|
||
}
|
||
|
||
class Resp {
|
||
final int status;
|
||
final String body;
|
||
final Map<String, dynamic> json;
|
||
Resp(this.status, this.body, this.json);
|
||
}
|
||
|
||
Future<Resp> call(
|
||
String method,
|
||
String url, {
|
||
String? token,
|
||
Object? body,
|
||
Map<String, String>? headers,
|
||
}) async {
|
||
final req = await client.openUrl(method, Uri.parse(url));
|
||
if (body != null) req.headers.contentType = ContentType.json;
|
||
if (token != null) req.headers.set('Authorization', 'Bearer $token');
|
||
headers?.forEach(req.headers.set);
|
||
if (body != null) req.write(jsonEncode(body));
|
||
final resp = await req.close();
|
||
final text = await utf8.decodeStream(resp);
|
||
Map<String, dynamic> parsed = const {};
|
||
try {
|
||
parsed = jsonDecode(text) as Map<String, dynamic>;
|
||
} catch (_) {
|
||
// 非 JSON 响应,parsed 留空 map,由调用方按 status 断言
|
||
}
|
||
return Resp(resp.statusCode, text, parsed);
|
||
}
|
||
|
||
String uuidV4() {
|
||
final rnd = Random.secure();
|
||
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||
final h = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||
return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-'
|
||
'${h.substring(16, 20)}-${h.substring(20)}';
|
||
}
|
||
|
||
void main() async {
|
||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||
final usernameA = 'e2e_m2_a_$ts';
|
||
final usernameB = 'e2e_m2_b_$ts';
|
||
const password = 'Test@123456';
|
||
final phoneA = '+8613${Random().nextInt(900000000) + 100000000}';
|
||
final phoneB = '+8613${Random().nextInt(900000000) + 100000000}';
|
||
|
||
print('=== Patbond M2 E2E 烟囱测试开始(T2-18)===');
|
||
print('账号 A: $usernameA');
|
||
print('账号 B: $usernameB');
|
||
print('');
|
||
|
||
try {
|
||
// ================================================================
|
||
// [1/11] 注册账号 A → 登录
|
||
// ================================================================
|
||
print('[1/11] 注册账号 A → 登录');
|
||
var r = await call(
|
||
'POST',
|
||
'$authUrl/api/v1/auth/register',
|
||
body: {'username': usernameA, 'phone': phoneA, 'password': password},
|
||
);
|
||
print(' POST /api/v1/auth/register → ${r.status}');
|
||
check(
|
||
r.status == 200 && r.json['code'] == 0,
|
||
'注册成功',
|
||
'注册失败: ${r.status} ${r.body}',
|
||
);
|
||
final userIdA = (r.json['data'] as Map)['userId'] as String;
|
||
print(' userId(A): $userIdA');
|
||
print(
|
||
' accessToken: ${redact((r.json['data'] as Map)['accessToken'] as String)}',
|
||
);
|
||
|
||
r = await call(
|
||
'POST',
|
||
'$authUrl/api/v1/auth/login',
|
||
body: {'username': usernameA, 'password': password},
|
||
);
|
||
print(' POST /api/v1/auth/login → ${r.status}');
|
||
check(
|
||
r.status == 200 && r.json['code'] == 0,
|
||
'登录成功(设备 1 会话)',
|
||
'登录失败: ${r.status} ${r.body}',
|
||
);
|
||
var tokenA = (r.json['data'] as Map)['accessToken'] as String;
|
||
print(' accessToken: ${redact(tokenA)}');
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [2/11] 建档(含品种)→ 列表/详情读回核对
|
||
// ================================================================
|
||
print('[2/11] 建档(POST /pets,含品种)→ 列表/详情读回核对');
|
||
r = await call('GET', '$petUrl/api/v1/breeds?species=dog', token: tokenA);
|
||
print(' GET /api/v1/breeds?species=dog → ${r.status}');
|
||
check(
|
||
r.status == 200 && (r.json['data'] as List).isNotEmpty,
|
||
'品种目录返回 ${(r.json['data'] as List).length} 条',
|
||
'品种目录读取失败: ${r.body}',
|
||
);
|
||
final breed = (r.json['data'] as List).first as Map<String, dynamic>;
|
||
final breedId = breed['id'] as String;
|
||
final breedName = breed['displayName'] as String;
|
||
print(' 选用品种: $breedName ($breedId)');
|
||
|
||
r = await call(
|
||
'POST',
|
||
'$petUrl/api/v1/pets',
|
||
token: tokenA,
|
||
body: {
|
||
'name': '旺财M2',
|
||
'species': 'dog',
|
||
'breedId': breedId,
|
||
'sex': 'male',
|
||
'birthDate': '2024-05-01',
|
||
'birthDateEstimated': false,
|
||
'personality': '活泼',
|
||
},
|
||
);
|
||
print(' POST /api/v1/pets → ${r.status}');
|
||
check(
|
||
r.status == 201 && r.json['code'] == 0,
|
||
'建档成功(201)',
|
||
'建档失败: ${r.status} ${r.body}',
|
||
);
|
||
final pet = r.json['data'] as Map<String, dynamic>;
|
||
final petId = pet['id'] as String;
|
||
final petVersion = pet['version'] as int;
|
||
print(' petId: $petId');
|
||
print(
|
||
' myRole: ${pet['myRole']} / version: $petVersion / '
|
||
'breedDisplayName: ${pet['breedDisplayName']}',
|
||
);
|
||
check(pet['myRole'] == 'owner', '创建者角色为 owner', 'myRole 非 owner');
|
||
check(
|
||
pet['breedDisplayName'] == breedName,
|
||
'品种展示名解出一致',
|
||
'breedDisplayName 不一致: ${pet['breedDisplayName']}',
|
||
);
|
||
|
||
r = await call('GET', '$petUrl/api/v1/pets', token: tokenA);
|
||
print(' GET /api/v1/pets → ${r.status}');
|
||
final petList = r.json['data'] as List;
|
||
check(
|
||
r.status == 200 &&
|
||
petList.length == 1 &&
|
||
(petList.first as Map)['id'] == petId,
|
||
'列表读回 1 只宠物且 id 一致',
|
||
'列表读回不符: ${r.body}',
|
||
);
|
||
|
||
r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA);
|
||
print(' GET /api/v1/pets/$petId → ${r.status}');
|
||
final detail = r.json['data'] as Map<String, dynamic>;
|
||
check(
|
||
r.status == 200 &&
|
||
detail['name'] == '旺财M2' &&
|
||
detail['species'] == 'dog' &&
|
||
detail['breedId'] == breedId &&
|
||
detail['status'] == 'active',
|
||
'详情读回核对通过(name/species/breedId/status)',
|
||
'详情读回不符: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [3/11] 记体重 ×2 → 列表分页读回
|
||
// ================================================================
|
||
print('[3/11] 记体重 ×2 → 列表 cursor 分页读回');
|
||
final now = DateTime.now().toUtc();
|
||
final measured1 = now.subtract(const Duration(days: 2)).toIso8601String();
|
||
final measured2 = now.subtract(const Duration(days: 1)).toIso8601String();
|
||
|
||
r = await call(
|
||
'POST',
|
||
'$petUrl/api/v1/pets/$petId/weights',
|
||
token: tokenA,
|
||
body: {'weightKg': 8.20, 'measuredAt': measured1, 'source': 'manual'},
|
||
);
|
||
print(' POST /weights (8.20kg, $measured1) → ${r.status}');
|
||
check(r.status == 201, '第一条体重创建成功', '体重创建失败: ${r.body}');
|
||
|
||
r = await call(
|
||
'POST',
|
||
'$petUrl/api/v1/pets/$petId/weights',
|
||
token: tokenA,
|
||
body: {
|
||
'weightKg': 8.45,
|
||
'measuredAt': measured2,
|
||
'source': 'manual',
|
||
'note': 'M2 烟囱',
|
||
},
|
||
);
|
||
print(' POST /weights (8.45kg, $measured2) → ${r.status}');
|
||
check(r.status == 201, '第二条体重创建成功', '体重创建失败: ${r.body}');
|
||
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/pets/$petId/weights?limit=1',
|
||
token: tokenA,
|
||
);
|
||
print(' GET /weights?limit=1 → ${r.status}');
|
||
var page = r.json['data'] as Map<String, dynamic>;
|
||
var items = page['items'] as List;
|
||
check(
|
||
r.status == 200 &&
|
||
items.length == 1 &&
|
||
(items.first as Map)['weightKg'] == 8.45 &&
|
||
page['hasMore'] == true &&
|
||
page['nextCursor'] != null,
|
||
'第一页:最新体重 8.45kg 在前,hasMore=true,nextCursor 非空',
|
||
'第一页分页不符: ${r.body}',
|
||
);
|
||
final cursor = page['nextCursor'] as String;
|
||
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/pets/$petId/weights?limit=1'
|
||
'&cursor=${Uri.encodeQueryComponent(cursor)}',
|
||
token: tokenA,
|
||
);
|
||
print(' GET /weights?limit=1&cursor=... → ${r.status}');
|
||
page = r.json['data'] as Map<String, dynamic>;
|
||
items = page['items'] as List;
|
||
check(
|
||
r.status == 200 &&
|
||
items.length == 1 &&
|
||
(items.first as Map)['weightKg'] == 8.2 &&
|
||
page['hasMore'] == false &&
|
||
page['nextCursor'] == null,
|
||
'第二页:8.20kg,hasMore=false,nextCursor=null',
|
||
'第二页分页不符: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [4/11] 登记疫苗(scheduled)→ 标记完成(PATCH,乐观锁)
|
||
// ================================================================
|
||
print('[4/11] 登记疫苗(scheduled)→ 标记完成(PATCH + version)');
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/vaccine-catalog?species=dog',
|
||
token: tokenA,
|
||
);
|
||
print(' GET /api/v1/vaccine-catalog?species=dog → ${r.status}');
|
||
check(
|
||
r.status == 200 && (r.json['data'] as List).isNotEmpty,
|
||
'疫苗目录返回 ${(r.json['data'] as List).length} 条',
|
||
'疫苗目录读取失败: ${r.body}',
|
||
);
|
||
final vaccine = (r.json['data'] as List).first as Map<String, dynamic>;
|
||
final vaccineId = vaccine['id'] as String;
|
||
final vaccineName = vaccine['name'] as String;
|
||
print(' 选用疫苗: $vaccineName ($vaccineId)');
|
||
|
||
final today = now.toIso8601String().substring(0, 10);
|
||
final nextDue = now
|
||
.add(const Duration(days: 365))
|
||
.toIso8601String()
|
||
.substring(0, 10);
|
||
|
||
r = await call(
|
||
'POST',
|
||
'$petUrl/api/v1/pets/$petId/vaccinations',
|
||
token: tokenA,
|
||
body: {
|
||
'vaccineId': vaccineId,
|
||
'seriesKey': 'primary',
|
||
'doseNo': 1,
|
||
'doseLabel': '第一针',
|
||
'status': 'scheduled',
|
||
'plannedOn': today,
|
||
},
|
||
);
|
||
print(' POST /vaccinations (scheduled, plannedOn=$today) → ${r.status}');
|
||
check(
|
||
r.status == 201 && r.json['code'] == 0,
|
||
'疫苗登记成功(scheduled)',
|
||
'疫苗登记失败: ${r.status} ${r.body}',
|
||
);
|
||
final vacc = r.json['data'] as Map<String, dynamic>;
|
||
final vaccinationId = vacc['id'] as String;
|
||
final vaccVersion = vacc['version'] as int;
|
||
print(
|
||
' vaccinationId: $vaccinationId / version: $vaccVersion / '
|
||
'vaccineName: ${vacc['vaccineName']}',
|
||
);
|
||
|
||
r = await call(
|
||
'PATCH',
|
||
'$petUrl/api/v1/vaccinations/$vaccinationId',
|
||
token: tokenA,
|
||
body: {
|
||
'version': vaccVersion,
|
||
'status': 'completed',
|
||
'administeredOn': today,
|
||
'nextDueOn': nextDue,
|
||
},
|
||
);
|
||
print(
|
||
' PATCH /vaccinations/$vaccinationId '
|
||
'(→completed, version=$vaccVersion) → ${r.status}',
|
||
);
|
||
final vaccDone = r.json['data'] as Map<String, dynamic>;
|
||
check(
|
||
r.status == 200 &&
|
||
vaccDone['status'] == 'completed' &&
|
||
vaccDone['administeredOn'] == today &&
|
||
vaccDone['nextDueOn'] == nextDue &&
|
||
vaccDone['version'] == vaccVersion + 1,
|
||
'标记完成成功,version $vaccVersion→${vaccDone['version']},'
|
||
'administeredOn/nextDueOn 回读一致',
|
||
'疫苗标记完成不符: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [5/11] 记健康事件(金额整数分)→ 时间线读回
|
||
// ================================================================
|
||
print('[5/11] 记健康事件(amountCents 整数分)→ 时间线读回');
|
||
final occurredAt = now.toIso8601String();
|
||
const amountCents = 12500; // 125.00 元
|
||
r = await call(
|
||
'POST',
|
||
'$petUrl/api/v1/pets/$petId/health-events',
|
||
token: tokenA,
|
||
body: {
|
||
'eventType': 'medical',
|
||
'occurredAt': occurredAt,
|
||
'title': 'M2 烟囱体检',
|
||
'notes': '含金额整数分核对',
|
||
'amountCents': amountCents,
|
||
},
|
||
);
|
||
print(
|
||
' POST /health-events (medical, amountCents=$amountCents) '
|
||
'→ ${r.status}',
|
||
);
|
||
check(
|
||
r.status == 201 && r.json['code'] == 0,
|
||
'健康事件创建成功',
|
||
'健康事件创建失败: ${r.status} ${r.body}',
|
||
);
|
||
final healthEvent = r.json['data'] as Map<String, dynamic>;
|
||
final healthEventId = healthEvent['id'] as String;
|
||
check(
|
||
healthEvent['amountCents'] == amountCents &&
|
||
healthEvent['createdByUserId'] == userIdA,
|
||
'amountCents=$amountCents 原样回读,createdByUserId=token subject',
|
||
'健康事件字段不符: ${r.body}',
|
||
);
|
||
print(' healthEventId: $healthEventId');
|
||
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/pets/$petId/health-events',
|
||
token: tokenA,
|
||
);
|
||
print(' GET /health-events → ${r.status}');
|
||
final timeline = (r.json['data'] as Map)['items'] as List;
|
||
check(
|
||
r.status == 200 &&
|
||
timeline.length == 1 &&
|
||
(timeline.first as Map)['id'] == healthEventId &&
|
||
(timeline.first as Map)['title'] == 'M2 烟囱体检',
|
||
'时间线读回 1 条且字段一致',
|
||
'时间线读回不符: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [6/11] 创建提醒 → 标记完成(completedAt 校验)
|
||
// ================================================================
|
||
print('[6/11] 创建提醒 → 标记完成(completedAt 校验)');
|
||
final dueAt = now.add(const Duration(days: 30)).toIso8601String();
|
||
r = await call(
|
||
'POST',
|
||
'$petUrl/api/v1/pets/$petId/care-reminders',
|
||
token: tokenA,
|
||
body: {'reminderType': 'deworming', 'title': '季度驱虫', 'dueAt': dueAt},
|
||
);
|
||
print(' POST /care-reminders (deworming, dueAt=$dueAt) → ${r.status}');
|
||
final reminder = r.json['data'] as Map<String, dynamic>;
|
||
check(
|
||
r.status == 201 &&
|
||
reminder['status'] == 'pending' &&
|
||
reminder['completedAt'] == null,
|
||
'提醒创建成功,恒为 pending 且 completedAt=null',
|
||
'提醒创建不符: ${r.status} ${r.body}',
|
||
);
|
||
final reminderId = reminder['id'] as String;
|
||
print(' reminderId: $reminderId');
|
||
|
||
final completedAt = now.toIso8601String();
|
||
r = await call(
|
||
'PATCH',
|
||
'$petUrl/api/v1/care-reminders/$reminderId',
|
||
token: tokenA,
|
||
body: {'status': 'completed', 'completedAt': completedAt},
|
||
);
|
||
print(' PATCH /care-reminders/$reminderId (→completed) → ${r.status}');
|
||
final reminderDone = r.json['data'] as Map<String, dynamic>;
|
||
check(
|
||
r.status == 200 &&
|
||
reminderDone['status'] == 'completed' &&
|
||
reminderDone['completedAt'] != null,
|
||
'标记完成成功,completedAt=${reminderDone['completedAt']}(客户端提交时刻回读)',
|
||
'提醒标记完成不符: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [7/11] 摘要核对:四项聚合逐项断言
|
||
// ================================================================
|
||
print('[7/11] GET /summary?tz=Asia/Shanghai 四项聚合逐项断言');
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/pets/$petId/summary?tz=Asia/Shanghai',
|
||
token: tokenA,
|
||
);
|
||
print(' GET /summary → ${r.status}');
|
||
check(
|
||
r.status == 200 && r.json['code'] == 0,
|
||
'摘要返回 200',
|
||
'摘要读取失败: ${r.status} ${r.body}',
|
||
);
|
||
final summary = r.json['data'] as Map<String, dynamic>;
|
||
|
||
final latestWeight = summary['latestWeight'] as Map<String, dynamic>?;
|
||
check(
|
||
latestWeight != null && latestWeight['weightKg'] == 8.45,
|
||
'最新体重 = 8.45kg(第二条写入,measured_at DESC 首行)',
|
||
'latestWeight 不符: $latestWeight',
|
||
);
|
||
|
||
final progress = summary['vaccinationProgress'] as Map<String, dynamic>?;
|
||
check(
|
||
progress != null &&
|
||
progress['completedDoses'] == 1 &&
|
||
progress['totalDoses'] == 1,
|
||
'疫苗进度 = 1/1(scheduled→completed 后)',
|
||
'vaccinationProgress 不符: $progress',
|
||
);
|
||
|
||
final nextVacc = summary['nextVaccination'] as Map<String, dynamic>?;
|
||
check(
|
||
nextVacc != null &&
|
||
nextVacc['vaccinationId'] == vaccinationId &&
|
||
nextVacc['dueOn'] == nextDue &&
|
||
nextVacc['source'] == 'nextDue',
|
||
'下次接种 = completed 行的 nextDueOn($nextDue,source=nextDue)',
|
||
'nextVaccination 不符: $nextVacc',
|
||
);
|
||
|
||
final expense = summary['monthlyExpense'] as Map<String, dynamic>;
|
||
final shanghaiNow = now.add(const Duration(hours: 8));
|
||
final expectMonth =
|
||
'${shanghaiNow.year}-'
|
||
'${shanghaiNow.month.toString().padLeft(2, '0')}';
|
||
check(
|
||
expense['amountCents'] == amountCents &&
|
||
expense['month'] == expectMonth &&
|
||
expense['timezone'] == 'Asia/Shanghai',
|
||
'当月花费 = $amountCents 分,month=$expectMonth,timezone 回显 Asia/Shanghai',
|
||
'monthlyExpense 不符: $expense',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [8/11] 权限拒绝:账号 B 访问 A 的宠物四路 → 404/40401 响应体一致
|
||
// ================================================================
|
||
print('[8/11] 注册账号 B → 用 B 的 token 访问 A 的宠物四路(防枚举核对)');
|
||
r = await call(
|
||
'POST',
|
||
'$authUrl/api/v1/auth/register',
|
||
body: {'username': usernameB, 'phone': phoneB, 'password': password},
|
||
);
|
||
print(' POST /api/v1/auth/register (B) → ${r.status}');
|
||
check(
|
||
r.status == 200 && r.json['code'] == 0,
|
||
'账号 B 注册成功',
|
||
'B 注册失败: ${r.body}',
|
||
);
|
||
final tokenB = (r.json['data'] as Map)['accessToken'] as String;
|
||
print(' accessToken(B): ${redact(tokenB)}');
|
||
|
||
final deniedRoutes = <String, String>{
|
||
'详情 GET /pets/{id}': '$petUrl/api/v1/pets/$petId',
|
||
'体重 GET /pets/{id}/weights': '$petUrl/api/v1/pets/$petId/weights',
|
||
'疫苗 GET /pets/{id}/vaccinations':
|
||
'$petUrl/api/v1/pets/$petId/vaccinations',
|
||
'摘要 GET /pets/{id}/summary': '$petUrl/api/v1/pets/$petId/summary',
|
||
};
|
||
final deniedBodies = <String>[];
|
||
for (final entry in deniedRoutes.entries) {
|
||
r = await call('GET', entry.value, token: tokenB);
|
||
print(' ${entry.key} → ${r.status} / code ${r.json['code']}');
|
||
check(
|
||
r.status == 404 && r.json['code'] == 40401,
|
||
'404/40401(${entry.key})',
|
||
'${entry.key} 未按防枚举拒绝: ${r.body}',
|
||
);
|
||
deniedBodies.add(r.body);
|
||
}
|
||
check(
|
||
deniedBodies.toSet().length == 1,
|
||
'四路响应体完全一致(防枚举):${deniedBodies.first}',
|
||
'四路响应体不一致: $deniedBodies',
|
||
);
|
||
|
||
r = await call('GET', '$petUrl/api/v1/pets', token: tokenB);
|
||
check(
|
||
r.status == 200 && (r.json['data'] as List).isEmpty,
|
||
'B 的宠物列表为空(列表天然隔离)',
|
||
'B 列表泄露: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [9/11] 跨设备读取:账号 A 重新登录(新会话)→ 全量数据读回
|
||
// ================================================================
|
||
print('[9/11] 账号 A 重新登录(模拟第二设备新会话)→ 全量数据读回');
|
||
r = await call(
|
||
'POST',
|
||
'$authUrl/api/v1/auth/login',
|
||
body: {'username': usernameA, 'password': password},
|
||
);
|
||
print(' POST /api/v1/auth/login (设备 2) → ${r.status}');
|
||
check(r.status == 200, '第二设备登录成功', '第二设备登录失败: ${r.body}');
|
||
final tokenA2 = (r.json['data'] as Map)['accessToken'] as String;
|
||
check(
|
||
tokenA2 != tokenA,
|
||
'新会话 token 与设备 1 不同(独立 token family)',
|
||
'两次登录 token 相同',
|
||
);
|
||
print(' accessToken(设备2): ${redact(tokenA2)}');
|
||
|
||
r = await call('GET', '$petUrl/api/v1/pets', token: tokenA2);
|
||
check(
|
||
r.status == 200 &&
|
||
(r.json['data'] as List).length == 1 &&
|
||
((r.json['data'] as List).first as Map)['name'] == '旺财M2',
|
||
'宠物列表:1 只(旺财M2)',
|
||
'设备 2 宠物列表不符: ${r.body}',
|
||
);
|
||
|
||
r = await call('GET', '$petUrl/api/v1/pets/$petId/weights', token: tokenA2);
|
||
check(
|
||
((r.json['data'] as Map)['items'] as List).length == 2,
|
||
'体重记录:2 条',
|
||
'设备 2 体重不符: ${r.body}',
|
||
);
|
||
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/pets/$petId/vaccinations',
|
||
token: tokenA2,
|
||
);
|
||
final vaccList = r.json['data'] as List;
|
||
check(
|
||
vaccList.length == 1 && (vaccList.first as Map)['status'] == 'completed',
|
||
'疫苗记录:1 条(completed)',
|
||
'设备 2 疫苗不符: ${r.body}',
|
||
);
|
||
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/pets/$petId/health-events',
|
||
token: tokenA2,
|
||
);
|
||
check(
|
||
((r.json['data'] as Map)['items'] as List).length == 1,
|
||
'健康事件:1 条',
|
||
'设备 2 健康事件不符: ${r.body}',
|
||
);
|
||
|
||
r = await call(
|
||
'GET',
|
||
'$petUrl/api/v1/pets/$petId/care-reminders',
|
||
token: tokenA2,
|
||
);
|
||
final remList = r.json['data'] as List;
|
||
check(
|
||
remList.length == 1 && (remList.first as Map)['status'] == 'completed',
|
||
'提醒:1 条(completed,completedAt=${(remList.first as Map)['completedAt']})',
|
||
'设备 2 提醒不符: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [10/11] 埋点链路:POST /api/v1/events 上报 v2 事件 → 202 逐条 accepted
|
||
// ================================================================
|
||
print('[10/11] POST /api/v1/events 上报 v2 事件(platform=android 模拟真机值)');
|
||
final anonymousId = uuidV4();
|
||
final sessionId = uuidV4();
|
||
final clientTs = DateTime.now().toUtc().toIso8601String();
|
||
Map<String, dynamic> baseEvent(String name, Map<String, dynamic> props) => {
|
||
'eventId': uuidV4(),
|
||
'eventName': name,
|
||
'eventVersion': 2,
|
||
'anonymousId': anonymousId,
|
||
'userId': userIdA,
|
||
'sessionId': sessionId,
|
||
'clientTs': clientTs,
|
||
'appVersion': '1.0.0+e2e',
|
||
'platform': 'android',
|
||
'osVersion': 'android-14',
|
||
'props': props,
|
||
};
|
||
final events = [
|
||
baseEvent('pet_create_succeeded', {
|
||
'durationMs': 1200,
|
||
'species': 'dog',
|
||
'petIndex': 1,
|
||
}),
|
||
baseEvent('health_record_create_succeeded', {
|
||
'recordType': 'weight',
|
||
'durationMs': 640,
|
||
}),
|
||
baseEvent('health_record_create_succeeded', {
|
||
'recordType': 'vaccine',
|
||
'durationMs': 820,
|
||
}),
|
||
baseEvent('page_viewed', {
|
||
'pageName': 'pet_detail',
|
||
'referrer': 'pet_list',
|
||
}),
|
||
];
|
||
for (final e in events) {
|
||
print(' eventId: ${e['eventId']} (${e['eventName']})');
|
||
}
|
||
r = await call(
|
||
'POST',
|
||
'$userUrl/api/v1/events',
|
||
token: tokenA2,
|
||
body: {'events': events},
|
||
);
|
||
print(' POST /api/v1/events (4 条) → ${r.status}');
|
||
check(
|
||
r.status == 202 && r.json['code'] == 0,
|
||
'批次受理 202',
|
||
'埋点上报失败: ${r.status} ${r.body}',
|
||
);
|
||
final trackData = r.json['data'] as Map<String, dynamic>;
|
||
final results = trackData['results'] as List;
|
||
final allAccepted = results.every(
|
||
(e) => (e as Map)['status'] == 'accepted',
|
||
);
|
||
check(
|
||
trackData['accepted'] == 4 &&
|
||
trackData['rejected'] == 0 &&
|
||
results.length == 4 &&
|
||
allAccepted,
|
||
'4/4 逐条 accepted(accepted=4, duplicated=0, rejected=0)',
|
||
'埋点结果不符: ${r.body}',
|
||
);
|
||
print(' 落库核对(platform.product_events)由报告附 psql 证据。');
|
||
print(' E2E_SESSION_ID=$sessionId'); // 供 psql 查证
|
||
_passed++;
|
||
print('');
|
||
|
||
// ================================================================
|
||
// [11/11] 乐观锁冲突明确性:两次 PATCH 同一 version → 40902
|
||
// ================================================================
|
||
print('[11/11] 两次 PATCH 宠物档案提交同一 version → 第二次 409/40902');
|
||
r = await call(
|
||
'PATCH',
|
||
'$petUrl/api/v1/pets/$petId',
|
||
token: tokenA,
|
||
body: {'version': petVersion, 'personality': '沉稳'},
|
||
);
|
||
print(' PATCH /pets/$petId (version=$petVersion, 第一次) → ${r.status}');
|
||
check(
|
||
r.status == 200 && (r.json['data'] as Map)['version'] == petVersion + 1,
|
||
'第一次 PATCH 成功,version $petVersion→${petVersion + 1}',
|
||
'第一次 PATCH 失败: ${r.body}',
|
||
);
|
||
|
||
r = await call(
|
||
'PATCH',
|
||
'$petUrl/api/v1/pets/$petId',
|
||
token: tokenA2,
|
||
body: {'version': petVersion, 'personality': '黏人'},
|
||
);
|
||
print(
|
||
' PATCH /pets/$petId (同一过期 version=$petVersion, 第二次/设备 2) '
|
||
'→ ${r.status}',
|
||
);
|
||
check(
|
||
r.status == 409 && r.json['code'] == 40902,
|
||
'第二次被明确拒绝:409/40902(${r.json['message']}),先写者数据保留',
|
||
'乐观锁冲突语义不符: ${r.status} ${r.body}',
|
||
);
|
||
|
||
r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA);
|
||
check(
|
||
(r.json['data'] as Map)['personality'] == '沉稳',
|
||
'读回确认先写者数据保留(personality=沉稳)',
|
||
'并发覆盖发生: ${r.body}',
|
||
);
|
||
_passed++;
|
||
print('');
|
||
|
||
print('=== M2 E2E 烟囱测试全部通过 ✓($_passed/11 场景)===');
|
||
print('E2E_USERNAME_A=$usernameA');
|
||
print('E2E_PET_ID=$petId');
|
||
} catch (e, stack) {
|
||
print('✗ 测试异常: $e');
|
||
print(stack);
|
||
exit(1);
|
||
} finally {
|
||
client.close();
|
||
}
|
||
}
|