From 720865bcb93fca5fe49340b77807fb174d193b91 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 14:42:30 +0800 Subject: [PATCH] =?UTF-8?q?test:=20M2=20E2E=20=E7=83=9F=E5=9B=B1=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=EF=BC=88T2-18=20=E6=94=B6=E5=AE=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test_e2e_m2_manual.dart | 777 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 test_e2e_m2_manual.dart diff --git a/test_e2e_m2_manual.dart b/test_e2e_m2_manual.dart new file mode 100644 index 0000000..206c95e --- /dev/null +++ b/test_e2e_m2_manual.dart @@ -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)→ 标记完成(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))}...'; + +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 json; + Resp(this.status, this.body, this.json); +} + +Future call( + String method, + String url, { + String? token, + Object? body, + Map? 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 parsed = const {}; + try { + parsed = jsonDecode(text) as Map; + } catch (_) { + // 非 JSON 响应,parsed 留空 map,由调用方按 status 断言 + } + return Resp(resp.statusCode, text, parsed); +} + +String uuidV4() { + final rnd = Random.secure(); + final bytes = List.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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + + final latestWeight = summary['latestWeight'] as Map?; + check( + latestWeight != null && latestWeight['weightKg'] == 8.45, + '最新体重 = 8.45kg(第二条写入,measured_at DESC 首行)', + 'latestWeight 不符: $latestWeight', + ); + + final progress = summary['vaccinationProgress'] as Map?; + check( + progress != null && + progress['completedDoses'] == 1 && + progress['totalDoses'] == 1, + '疫苗进度 = 1/1(scheduled→completed 后)', + 'vaccinationProgress 不符: $progress', + ); + + final nextVacc = summary['nextVaccination'] as Map?; + 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; + 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 = { + '详情 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 = []; + 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 baseEvent(String name, Map 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; + 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(); + } +}