test: M2 E2E 烟囱脚本(T2-18 收官)
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>
This commit is contained in:
2026-09-08 14:42:30 +08:00
parent ba503327f5
commit 720865bcb9
+777
View File
@@ -0,0 +1,777 @@
#!/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)→ 标记完成(PATCHversion 乐观锁)
/// 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-petpets 域 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=truenextCursor 非空',
'第一页分页不符: ${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.20kghasMore=falsenextCursor=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/1scheduled→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$nextDuesource=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=$expectMonthtimezone 回显 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 条(completedcompletedAt=${(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 逐条 acceptedaccepted=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();
}
}