Files
patbond-flutter/test_e2e_m2_manual.dart
lixi 720865bcb9
CI / flutter-gates (push) Successful in 2m12s
test: M2 E2E 烟囱脚本(T2-18 收官)
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>
2026-09-08 14:42:30 +08:00

778 lines
27 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.
#!/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();
}
}