From 0e87413360ae47f6a3ce8aa4442d1218d95d0ed5 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Thu, 10 Sep 2026 15:02:48 +0800 Subject: [PATCH] =?UTF-8?q?test:=20M3=20E2E=20=E7=83=9F=E5=9B=B1=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=EF=BC=88T3-21=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 对 compose 六容器真实后端跑通社区全链路 14 场景,逐条取证 M3 四条验收标准: ① 发布后另一客户端 Feed 可见(FeedCard 字段/裁剪/AuthorSummary 逐项断言) ② 重复点赞不重复计数(3 次 PUT → likeCount 恰为 1,权威终态非 409) ③ 游标分页不丢不重(25 帖 + 双粒度全量翻页逐位比对) ④ 软删内容不再出现在公共 Feed(全量翻页 + 直读/互动面 404/40403) 另覆盖两步上传直传 MinIO 与预签名 GET 字节往返、无签名直访 403、收藏列表、 评论「仅作者可删」拍板语义、关注计数与 42204/自取关 no-op、草稿防枚举逐字节 一致、v3 社区埋点落库与字典边界、幂等键同/异 hash 与按作者隔离。 脚本与 M1/M2 版并列放仓库根目录(不进 test/,不被 flutter test 收集); token 与预签名 URL 全程脱敏。 Co-Authored-By: Claude Fable 5 --- test_e2e_m3_manual.dart | 1373 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1373 insertions(+) create mode 100644 test_e2e_m3_manual.dart diff --git a/test_e2e_m3_manual.dart b/test_e2e_m3_manual.dart new file mode 100644 index 0000000..87d5818 --- /dev/null +++ b/test_e2e_m3_manual.dart @@ -0,0 +1,1373 @@ +#!/usr/bin/env dart + +// ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。 +/// M3 E2E 烟囱测试脚本(T3-21 收官):对 compose 真实后端跑通社区全链路。 +/// +/// 前置条件:patbond-api 目录执行 `docker compose up -d`(六容器: +/// postgres + minio + auth:8081 + user:8082 + pet:8083 + community:8084) +/// 运行方式:dart run test_e2e_m3_manual.dart +/// +/// 覆盖 14 个场景(工单 T3-21 定义的链路,冻结契约 openapi v1.3.0): +/// 1. 注册账号 A、B(:8081) +/// 2. A 两步上传图片:createUpload(:8082)→ 预签名 PUT 直传 MinIO → confirm ready +/// 3. A 创建草稿(:8084,Idempotency-Key 必带)→ 引用 ready asset → PATCH 发布 +/// 4. A 的帖在 B 的 Feed 首位可见,FeedCard 字段完整(**M3 验收标准 ①**) +/// 5. 预签名 GET 取回图片字节与上传一致 +/// 6. B 重复点赞幂等:3 次 PUT → likeCount 恰为 1;DELETE → 0;再 DELETE 幂等 +/// (**M3 验收标准 ②**) +/// 7. B 收藏 → /me/bookmarks 含该帖;取消收藏后不含 +/// 8. B 评论 ×2 → A 拉列表可见;B 删自己评论成功;A 删 B 的评论被拒(仅评论作者可删) +/// 9. B 关注 A + follow-stats 计数;自关注 422/42204;自取关 200 no-op +/// 10. Feed 游标分页不丢不重:A 批量发 25 帖 → 双粒度全量翻页比对(**M3 验收标准 ③**) +/// 11. 软删帖出 Feed:A 软删一帖 → B 的 Feed 不再含该帖,直接 GET 404/40403 +/// (**M3 验收标准 ④**) +/// 12. 防枚举一致性:B 访问 A 的草稿 与 随机 UUID → 响应体逐字节一致 +/// 13. 埋点:POST /api/v1/events(:8082)上报 v3 社区事件 → 202 逐条 accepted +/// 14. 幂等重放:同 Idempotency-Key 同 hash → 返回原帖;异 hash → 409/40905 +/// +/// 真机四项(device-verification.md)不在本脚本范围内,按方案 A 挂起。 +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +const authUrl = 'http://127.0.0.1:8081'; // patbond-auth +const userUrl = 'http://127.0.0.1:8082'; // patbond-user(/events、media 两步上传) +const communityUrl = 'http://127.0.0.1:8084'; // patbond-community(社区 19 操作) + +/// 固定测试图:1×1 JPEG(344 字节),sha256 为下方常量。 +/// complete 仅 HEAD 校验 byteSize/Content-Type,内容不核验(契约 v1.3.0), +/// 但用真 JPEG 让宽高回填与预签名 GET 字节比对都走真路径。 +const testJpegBase64 = + '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof' + 'Gh0aHBwcJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPDIzNP/AABEIAAEAAQMBIgACEQEDEQH/' + 'xAAfAAABBQEBAQEBAQAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQEC' + 'AwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5' + 'OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Sl' + 'pqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/a' + 'AAwDAQACEQMRAD8A9/ooooA//9k='; + +/// 上方常量解码后的 sha256(`sha256sum` 实测)——契约照收照存,不做内容核验。 +const testJpegSha256 = + '32142d9c5636535cf882b05c03275d2b58150623df750b059db0bbdab6973ce8'; + +/// 场景 10 批量发帖数(验收标准 ③ 要求 25+)。 +const bulkPostCount = 25; + +final client = HttpClient(); +int _passed = 0; + +/// token 脱敏:仅留前 20 字符。 +String redact(String token) => + '${token.substring(0, min(20, token.length))}...'; + +/// 预签名 URL 脱敏:保留 host + 对象路径,签名 query 整体抹掉。 +String redactSignedUrl(String url) { + final uri = Uri.parse(url); + return '${uri.scheme}://${uri.host}:${uri.port}${uri.path}' + '?'; +} + +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); + + Map get data => json['data'] as Map; + int? get code => json['code'] as int?; +} + +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); +} + +/// 预签名 PUT 直传:不带 Bearer(鉴权即 URL 签名本身),requiredHeaders 原样携带, +/// contentLength 显式设置(与 lib/features/community/media_direct_upload.dart 同构)。 +Future presignedPut( + String url, + Map requiredHeaders, + Uint8List bytes, +) async { + final req = await client.openUrl('PUT', Uri.parse(url)); + requiredHeaders.forEach(req.headers.set); + req.contentLength = bytes.length; + req.add(bytes); + final resp = await req.close(); + await resp.drain(); + return resp.statusCode; +} + +/// 预签名 GET:取回对象原始字节(桶私有,无签名直访被拒)。 +Future<(int, Uint8List)> presignedGet(String url) async { + final req = await client.openUrl('GET', Uri.parse(url)); + final resp = await req.close(); + final chunks = []; + await resp.forEach(chunks.addAll); + return (resp.statusCode, Uint8List.fromList(chunks)); +} + +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)}'; +} + +/// 全量翻页 Feed:返回 (有序 id 列表, 页数)。 +/// 页上限兜底防死循环(游标语义破裂时立即暴露而非挂死)。 +Future<(List, int)> drainFeed(String token, int limit) async { + final ids = []; + String? cursor; + var pages = 0; + while (true) { + final url = + '$communityUrl/api/v1/feed?limit=$limit' + '${cursor == null ? '' : '&cursor=${Uri.encodeQueryComponent(cursor)}'}'; + final r = await call('GET', url, token: token); + if (r.status != 200) fail('Feed 翻页失败(第 ${pages + 1} 页): ${r.body}'); + pages++; + final page = r.data; + for (final item in page['items'] as List) { + ids.add((item as Map)['id'] as String); + } + final hasMore = page['hasMore'] as bool; + cursor = page['nextCursor'] as String?; + if (!hasMore) { + if (cursor != null) fail('末页 nextCursor 非 null: $cursor'); + break; + } + if (cursor == null) fail('hasMore=true 但 nextCursor 为 null'); + if (pages > 500) fail('翻页超过 500 页,疑似游标不推进'); + } + return (ids, pages); +} + +/// 创建帖子(Idempotency-Key 必带)。 +Future createPost( + String token, + Map body, { + String? idempotencyKey, +}) => call( + 'POST', + '$communityUrl/api/v1/posts', + token: token, + body: body, + headers: {'Idempotency-Key': idempotencyKey ?? uuidV4()}, +); + +void main() async { + final ts = DateTime.now().millisecondsSinceEpoch; + final usernameA = 'e2e_m3_a_$ts'; + final usernameB = 'e2e_m3_b_$ts'; + const password = 'Test@123456'; + final phoneA = '+8613${Random().nextInt(900000000) + 100000000}'; + final phoneB = '+8613${Random().nextInt(900000000) + 100000000}'; + final imageBytes = base64Decode(testJpegBase64); + + print('=== Patbond M3 E2E 烟囱测试开始(T3-21 收官)==='); + print('账号 A: $usernameA'); + print('账号 B: $usernameB'); + print('测试图: ${imageBytes.length} 字节 image/jpeg,sha256=$testJpegSha256'); + print(''); + + try { + // ================================================================ + // [1/14] 注册账号 A、B(:8081) + // ================================================================ + print('[1/14] 注册账号 A、B(:8081)'); + var r = await call( + 'POST', + '$authUrl/api/v1/auth/register', + body: {'username': usernameA, 'phone': phoneA, 'password': password}, + ); + print(' POST /api/v1/auth/register (A) → ${r.status}'); + check(r.status == 200 && r.code == 0, 'A 注册成功', 'A 注册失败: ${r.body}'); + final userIdA = r.data['userId'] as String; + final tokenA = r.data['accessToken'] as String; + print(' userId(A): $userIdA'); + print(' accessToken(A): ${redact(tokenA)}'); + + 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.code == 0, 'B 注册成功', 'B 注册失败: ${r.body}'); + final userIdB = r.data['userId'] as String; + final tokenB = r.data['accessToken'] as String; + print(' userId(B): $userIdB'); + print(' accessToken(B): ${redact(tokenB)}'); + check(userIdA != userIdB, 'A/B 为两个独立账号(模拟两客户端)', 'A/B userId 相同'); + _passed++; + print(''); + + // ================================================================ + // [2/14] 两步上传:createUpload → 预签名 PUT 直传 MinIO → confirm ready + // ================================================================ + print('[2/14] A 两步上传图片:createUpload(:8082)→ 预签名 PUT → confirm'); + r = await call( + 'POST', + '$userUrl/api/v1/media/uploads', + token: tokenA, + body: { + 'kind': 'image', + 'purpose': 'post_image', + 'mimeType': 'image/jpeg', + 'byteSize': imageBytes.length, + 'sha256': testJpegSha256, + }, + ); + print(' POST /api/v1/media/uploads → ${r.status}'); + check( + r.status == 201 && r.code == 0, + 'asset 登记成功(201),返回预签名直传凭据', + 'createUpload 失败: ${r.status} ${r.body}', + ); + final creds = r.data; + final assetId = creds['assetId'] as String; + final uploadUrl = creds['uploadUrl'] as String; + final requiredHeaders = (creds['requiredHeaders'] as Map).map( + (k, v) => MapEntry(k as String, v as String), + ); + print(' assetId: $assetId'); + print(' uploadUrl: ${redactSignedUrl(uploadUrl)}'); + print(' method: ${creds['method']} / expiresAt: ${creds['expiresAt']}'); + print(' requiredHeaders: $requiredHeaders'); + check( + creds['method'] == 'PUT' && + requiredHeaders.length == 1 && + requiredHeaders['Content-Type'] == 'image/jpeg', + 'requiredHeaders 恒且仅一键 {Content-Type: image/jpeg}', + 'requiredHeaders 形态不符: $requiredHeaders', + ); + check( + Uri.parse(uploadUrl).queryParameters.containsKey('X-Amz-Signature'), + '预签名 URL 携带 SigV4 query 签名族(直传不经应用服务器)', + 'uploadUrl 无 X-Amz-Signature', + ); + + final putStatus = await presignedPut( + uploadUrl, + requiredHeaders, + imageBytes, + ); + print( + ' PUT (${imageBytes.length} 字节,原样携带 requiredHeaders)' + ' → $putStatus', + ); + check(putStatus == 200, '直传 MinIO 成功(存储侧接受签名)', '直传失败: HTTP $putStatus'); + + r = await call( + 'POST', + '$userUrl/api/v1/media/uploads/$assetId/complete', + token: tokenA, + ); + print(' POST /api/v1/media/uploads/$assetId/complete → ${r.status}'); + final asset = r.data; + check( + r.status == 200 && + asset['status'] == 'ready' && + asset['readyAt'] != null && + asset['url'] != null && + asset['byteSize'] == imageBytes.length, + 'uploading→ready(byteSize=${asset['byteSize']},' + 'widthPx=${asset['widthPx']} heightPx=${asset['heightPx']},' + 'readyAt 已写,url 现签非空)', + 'confirm 不符: ${r.status} ${r.body}', + ); + print(' asset.url: ${redactSignedUrl(asset['url'] as String)}'); + + r = await call( + 'POST', + '$userUrl/api/v1/media/uploads/$assetId/complete', + token: tokenA, + ); + print(' POST .../complete(重复确认)→ ${r.status}'); + check( + r.status == 200 && r.data['id'] == assetId && r.data['status'] == 'ready', + '已 ready 重复 complete 幂等 200 同一 asset(现签新 GET URL)', + '重复 complete 不幂等: ${r.status} ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [3/14] A 创建草稿(Idempotency-Key 必带)→ 引用 ready asset → PATCH 发布 + // ================================================================ + print('[3/14] A 创建草稿(:8084,引用 ready asset)→ PATCH 发布'); + const postTitle = 'M3 烟囱主贴'; + final postContent = 'M3 收官烟囱:两步上传 + 草稿发布 + 互动全链路。ts=$ts'; + final mainKey = uuidV4(); + r = await createPost(tokenA, { + 'title': postTitle, + 'content': postContent, + 'category': 'general', + 'status': 'draft', + 'media': [ + {'assetId': assetId, 'caption': '烟囱测试图'}, + ], + }, idempotencyKey: mainKey); + print( + ' POST /api/v1/posts (status=draft, Idempotency-Key 已带) ' + '→ ${r.status}', + ); + check( + r.status == 201 && r.code == 0, + '草稿创建成功(201)', + '草稿创建失败: ${r.status} ${r.body}', + ); + final draftPost = r.data; + final postId = draftPost['id'] as String; + final draftVersion = draftPost['version'] as int; + print( + ' postId: $postId / status: ${draftPost['status']} / ' + 'version: $draftVersion / publishedAt: ${draftPost['publishedAt']}', + ); + check( + draftPost['status'] == 'draft' && draftPost['publishedAt'] == null, + '草稿态 status=draft 且 publishedAt=null(发布时才恰写一次)', + '草稿态字段不符: ${r.body}', + ); + final draftMedia = draftPost['media'] as List; + check( + draftMedia.length == 1 && + (draftMedia.first as Map)['assetId'] == assetId && + (draftMedia.first as Map)['position'] == 0 && + (draftMedia.first as Map)['isCover'] == true, + 'media 挂接 1 图:position=0(按数组序)、isCover 由服务端置真(库内恒有唯一封面)', + 'media 挂接不符: $draftMedia', + ); + + r = await call( + 'PATCH', + '$communityUrl/api/v1/posts/$postId', + token: tokenA, + body: {'version': draftVersion, 'status': 'published'}, + ); + print( + ' PATCH /api/v1/posts/$postId (draft→published, ' + 'version=$draftVersion) → ${r.status}', + ); + final published = r.data; + check( + r.status == 200 && + published['status'] == 'published' && + published['publishedAt'] != null && + published['version'] == draftVersion + 1, + '发布成功:status=published,publishedAt 已写,' + 'version $draftVersion→${published['version']}', + '发布不符: ${r.status} ${r.body}', + ); + print(' publishedAt: ${published['publishedAt']}'); + final publishedMediaUrl = + ((published['media'] as List).first as Map)['url'] as String; + print(' media[0].url: ${redactSignedUrl(publishedMediaUrl)}'); + _passed++; + print(''); + + // ================================================================ + // [4/14] 验收标准 ①:A 的帖在 B 的 Feed 可见(另一客户端) + // ================================================================ + print('[4/14] 【验收①】B 拉 /api/v1/feed → A 的帖首位可见,FeedCard 字段完整'); + r = await call('GET', '$communityUrl/api/v1/feed?limit=5', token: tokenB); + print(' GET /api/v1/feed?limit=5 (B 的 token) → ${r.status}'); + check(r.status == 200 && r.code == 0, 'Feed 返回 200', 'Feed 失败: ${r.body}'); + final feedItems = r.data['items'] as List; + check( + feedItems.isNotEmpty && (feedItems.first as Map)['id'] == postId, + 'A 刚发布的帖在 B 的 Feed 首位(published_at DESC)', + 'Feed 首位非该帖: ${feedItems.isEmpty ? '空' : (feedItems.first as Map)['id']}', + ); + final card = feedItems.first as Map; + final cardAuthor = card['author'] as Map; + print( + ' FeedCard: title=${card['title']} / mediaCount=${card['mediaCount']}' + ' / like=${card['likeCount']} comment=${card['commentCount']}' + ' bookmark=${card['bookmarkCount']}', + ); + print( + ' author: userId=${cardAuthor['userId']} ' + 'nickname=${cardAuthor['nickname']}', + ); + check( + cardAuthor['userId'] == userIdA && cardAuthor.containsKey('nickname'), + 'AuthorSummary 归因 A 且 nickname 键在(空昵称已由服务端回退 username)', + 'AuthorSummary 不符: $cardAuthor', + ); + check( + !cardAuthor.containsKey('bio') && !cardAuthor.containsKey('username'), + 'AuthorSummary 不露 bio / username', + 'AuthorSummary 泄露隐私字段: ${cardAuthor.keys}', + ); + check( + card['title'] == postTitle && + card['contentPreview'] == postContent && + card['category'] == 'general' && + card['mediaCount'] == 1 && + card['likeCount'] == 0 && + card['commentCount'] == 0 && + card['bookmarkCount'] == 0 && + card['likedByMe'] == false && + card['bookmarkedByMe'] == false && + card['publishedAt'] != null, + 'FeedCard 必填齐备:contentPreview 原样透传(<200 码点)、mediaCount=1、' + '三计数为 0、B 视角 likedByMe/bookmarkedByMe=false、publishedAt 非空', + 'FeedCard 字段不符: ${jsonEncode(card)}', + ); + final coverImage = card['coverImage'] as Map?; + check( + coverImage != null && + coverImage['assetId'] == assetId && + coverImage['isCover'] == true && + (coverImage['url'] as String).isNotEmpty, + 'coverImage = 唯一 is_cover 行(assetId 命中,url 现签非空)', + 'coverImage 不符: $coverImage', + ); + check( + !card.containsKey('content') && + !card.containsKey('media') && + !card.containsKey('version'), + 'FeedCard 裁剪生效:不带 content 全文 / media 整组 / version', + 'FeedCard 未按契约裁剪: ${card.keys}', + ); + _passed++; + print(''); + + // ================================================================ + // [5/14] 预签名 GET 取回图片字节与上传一致 + // ================================================================ + print('[5/14] 预签名 GET 取回图片字节 → 与上传字节逐字节比对'); + final coverUrl = (coverImage!['url'] as String); + print(' GET ${redactSignedUrl(coverUrl)}'); + final (getStatus, downloaded) = await presignedGet(coverUrl); + print(' GET → $getStatus(${downloaded.length} 字节)'); + check(getStatus == 200, '预签名 GET 取回 200', '预签名 GET 失败: HTTP $getStatus'); + check( + downloaded.length == imageBytes.length && + const ListEquality().equals(downloaded, imageBytes), + '取回 ${downloaded.length} 字节与上传逐字节一致(内容往返无损)', + '字节不一致:上传 ${imageBytes.length} / 取回 ${downloaded.length}', + ); + + final unsignedUrl = Uri.parse(coverUrl).replace(query: '').toString(); + final (unsignedStatus, _) = await presignedGet(unsignedUrl); + print(' GET <同一对象但去掉签名> → $unsignedStatus'); + check( + unsignedStatus == 403, + '桶保持私有:无签名直访被存储侧拒绝(403)', + '无签名直访未被拒: HTTP $unsignedStatus', + ); + _passed++; + print(''); + + // ================================================================ + // [6/14] 验收标准 ②:B 重复点赞不重复计数 + // ================================================================ + print( + '[6/14] 【验收②】B 连续 3 次 PUT like → likeCount 恰为 1;' + 'DELETE → 0;再 DELETE 幂等', + ); + for (var i = 1; i <= 3; i++) { + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$postId/like', + token: tokenB, + ); + print( + ' PUT /like(第 $i 次)→ ${r.status} ' + '${jsonEncode(r.data)}', + ); + check( + r.status == 200 && r.data['liked'] == true && r.data['likeCount'] == 1, + '第 $i 次返回权威终态 {liked:true, likeCount:1}(非 409)', + '第 $i 次点赞终态不符: ${r.status} ${r.body}', + ); + } + r = await call('GET', '$communityUrl/api/v1/posts/$postId', token: tokenB); + check( + r.status == 200 && + r.data['likeCount'] == 1 && + r.data['likedByMe'] == true, + '详情读回 likeCount=1(3 次 PUT 仅实际插入一次才 +1)', + '详情 likeCount 不符: ${r.body}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/posts/$postId/like', + token: tokenB, + ); + print(' DELETE /like → ${r.status} ${jsonEncode(r.data)}'); + check( + r.status == 200 && r.data['liked'] == false && r.data['likeCount'] == 0, + '取消点赞返回 {liked:false, likeCount:0}', + '取消点赞不符: ${r.status} ${r.body}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/posts/$postId/like', + token: tokenB, + ); + print(' DELETE /like(重复)→ ${r.status} ${jsonEncode(r.data)}'); + check( + r.status == 200 && r.data['liked'] == false && r.data['likeCount'] == 0, + '取消不存在的点赞不报错不减计数(DELETE 语义幂等)', + '重复取消不幂等: ${r.status} ${r.body}', + ); + + // 恢复一个点赞,供后续 Feed 卡片计数观察。 + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$postId/like', + token: tokenB, + ); + check( + r.status == 200 && r.data['likeCount'] == 1, + '再次点赞恢复 likeCount=1(供后续卡片计数观察)', + '恢复点赞失败: ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [7/14] B 收藏 + /me/bookmarks 含该帖;取消收藏后不含 + // ================================================================ + print('[7/14] B 收藏 → GET /api/v1/me/bookmarks 含该帖;取消收藏后不含'); + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$postId/bookmark', + token: tokenB, + ); + print(' PUT /bookmark → ${r.status} ${jsonEncode(r.data)}'); + check( + r.status == 200 && + r.data['bookmarked'] == true && + r.data['bookmarkCount'] == 1, + '收藏返回权威终态 {bookmarked:true, bookmarkCount:1}', + '收藏不符: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/bookmarks?limit=20', + token: tokenB, + ); + print(' GET /api/v1/me/bookmarks → ${r.status}'); + var bookmarkIds = (r.data['items'] as List) + .map((e) => (e as Map)['id'] as String) + .toList(); + check( + r.status == 200 && bookmarkIds.contains(postId), + '收藏列表含该帖(共 ${bookmarkIds.length} 条,项形态 = FeedCard)', + '收藏列表不含该帖: $bookmarkIds', + ); + final bookmarkCard = + (r.data['items'] as List).firstWhere((e) => (e as Map)['id'] == postId) + as Map; + check( + bookmarkCard['bookmarkedByMe'] == true && + bookmarkCard['likedByMe'] == true && + bookmarkCard['publishedAt'] != null, + '收藏项 bookmarkedByMe/likedByMe 为 B 视角,publishedAt 恒非空', + '收藏项视角字段不符: ${jsonEncode(bookmarkCard)}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/posts/$postId/bookmark', + token: tokenB, + ); + print(' DELETE /bookmark → ${r.status} ${jsonEncode(r.data)}'); + check( + r.status == 200 && + r.data['bookmarked'] == false && + r.data['bookmarkCount'] == 0, + '取消收藏返回 {bookmarked:false, bookmarkCount:0}', + '取消收藏不符: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/bookmarks?limit=20', + token: tokenB, + ); + bookmarkIds = (r.data['items'] as List) + .map((e) => (e as Map)['id'] as String) + .toList(); + check( + r.status == 200 && !bookmarkIds.contains(postId), + '取消收藏后列表不含该帖(剩 ${bookmarkIds.length} 条)', + '取消收藏后仍在列表: $bookmarkIds', + ); + _passed++; + print(''); + + // ================================================================ + // [8/14] B 评论 → A 可见;B 删自己评论成功;A 删 B 的评论被拒 + // ================================================================ + print( + '[8/14] B 评论 ×2 → A 拉列表可见;B 删自己评论成功;' + 'A(帖主)删 B 的评论被拒', + ); + r = await call( + 'POST', + '$communityUrl/api/v1/posts/$postId/comments', + token: tokenB, + body: {'content': 'B 的第一条评论(将被 B 自己删除)'}, + headers: {'Idempotency-Key': uuidV4()}, + ); + print(' POST /comments (B, #1) → ${r.status}'); + check( + r.status == 201 && r.code == 0, + 'B 评论 #1 创建成功', + 'B 评论 #1 失败: ${r.body}', + ); + final commentId1 = r.data['id'] as String; + check( + (r.data['author'] as Map)['userId'] == userIdB && + r.data['postId'] == postId && + r.data['replyToUser'] == null && + !r.data.containsKey('updatedAt'), + '评论作者归因 B、postId 一致、非回复 replyToUser=null、无 updatedAt(M3 无编辑)', + '评论 #1 字段不符: ${r.body}', + ); + + r = await call( + 'POST', + '$communityUrl/api/v1/posts/$postId/comments', + token: tokenB, + body: { + 'content': 'B 的第二条评论(@A 回复,留作 A 越权删除测试)', + 'replyToUserId': userIdA, + }, + headers: {'Idempotency-Key': uuidV4()}, + ); + print(' POST /comments (B, #2, replyToUserId=A) → ${r.status}'); + check( + r.status == 201 && r.code == 0, + 'B 评论 #2 创建成功(@ 回复)', + 'B 评论 #2 失败: ${r.body}', + ); + final commentId2 = r.data['id'] as String; + check( + (r.data['replyToUser'] as Map?)?['userId'] == userIdA, + '@ 回复目标解出 AuthorSummary(单层平铺,无 parentCommentId)', + 'replyToUser 不符: ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/posts/$postId/comments?limit=20', + token: tokenA, + ); + print(' GET /comments (A 的 token) → ${r.status}'); + var commentIds = (r.data['items'] as List) + .map((e) => (e as Map)['id'] as String) + .toList(); + check( + r.status == 200 && + commentIds.length == 2 && + commentIds.first == commentId2 && + commentIds.contains(commentId1), + 'A 拉评论列表可见 B 的两条(created_at DESC,#2 在前)', + 'A 侧评论列表不符: $commentIds', + ); + r = await call('GET', '$communityUrl/api/v1/posts/$postId', token: tokenA); + check( + r.data['commentCount'] == 2, + 'commentCount 同事务 +1 累计为 2', + 'commentCount 不符: ${r.data['commentCount']}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/comments/$commentId1', + token: tokenB, + ); + print(' DELETE /comments/$commentId1 (B 删自己的) → ${r.status}'); + check( + r.status == 200 && r.code == 0, + 'B 删自己的评论成功(软删 status→deleted)', + 'B 删自己评论失败: ${r.status} ${r.body}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/comments/$commentId2', + token: tokenA, + ); + print( + ' DELETE /comments/$commentId2 (A 删 B 的) → ${r.status} / ' + 'code ${r.code}', + ); + check( + r.status == 403 && r.code == 40301, + 'A(帖主)删 B 的评论被拒 403/40301(${r.json['message']})——仅评论作者可删(D3-7 拍板)', + '越权删评论未被拒: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/posts/$postId/comments?limit=20', + token: tokenA, + ); + commentIds = (r.data['items'] as List) + .map((e) => (e as Map)['id'] as String) + .toList(); + check( + commentIds.length == 1 && commentIds.single == commentId2, + '删后列表仅剩 #2(仅 visible 评论),越权目标未被删除', + '删后列表不符: $commentIds', + ); + r = await call('GET', '$communityUrl/api/v1/posts/$postId', token: tokenA); + check( + r.data['commentCount'] == 1, + 'commentCount 同事务 -1 回到 1', + 'commentCount 不符: ${r.data['commentCount']}', + ); + _passed++; + print(''); + + // ================================================================ + // [9/14] B 关注 A + follow-stats;自关注 42204;自取关 200 no-op + // ================================================================ + print('[9/14] B 关注 A(PUT)+ follow-stats 计数;自关注 42204;自取关 200 no-op'); + r = await call( + 'PUT', + '$communityUrl/api/v1/users/$userIdA/follow', + token: tokenB, + ); + print(' PUT /users/{A}/follow (B) → ${r.status} ${jsonEncode(r.data)}'); + check( + r.status == 200 && + r.data['following'] == true && + r.data['followerCount'] == 1, + '关注成功:{following:true, followerCount:1}(A 的粉丝数)', + '关注不符: ${r.status} ${r.body}', + ); + + r = await call( + 'PUT', + '$communityUrl/api/v1/users/$userIdA/follow', + token: tokenB, + ); + print(' PUT /users{A}/follow(重复)→ ${r.status} ${jsonEncode(r.data)}'); + check( + r.status == 200 && r.data['followerCount'] == 1, + '重复关注幂等 200,粉丝数仍为 1(主键 (follower,followee) 即幂等键)', + '重复关注不幂等: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/users/$userIdA/follow-stats', + token: tokenB, + ); + print( + ' GET /users/{A}/follow-stats (B 视角) → ${r.status} ' + '${jsonEncode(r.data)}', + ); + check( + r.status == 200 && + r.data['followerCount'] == 1 && + r.data['followingCount'] == 0 && + r.data['followedByMe'] == true, + 'A 的计数:粉丝 1 / 关注 0,followedByMe=true(B 视角)', + 'follow-stats(B 视角) 不符: ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/users/$userIdA/follow-stats', + token: tokenA, + ); + print( + ' GET /users/{A}/follow-stats (A 查自己) → ${r.status} ' + '${jsonEncode(r.data)}', + ); + check( + r.status == 200 && + r.data['followerCount'] == 1 && + r.data['followedByMe'] == false, + 'A 查自己 followedByMe 恒 false(计数一致)', + 'follow-stats(自查) 不符: ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/users/$userIdB/follow-stats', + token: tokenB, + ); + check( + r.status == 200 && + r.data['followingCount'] == 1 && + r.data['followerCount'] == 0, + 'B 的计数:关注 1 / 粉丝 0(实时 COUNT,无冗余计数列)', + 'follow-stats(B) 不符: ${r.body}', + ); + + r = await call( + 'PUT', + '$communityUrl/api/v1/users/$userIdB/follow', + token: tokenB, + ); + print(' PUT /users/{B}/follow (B 自关注) → ${r.status} / code ${r.code}'); + check( + r.status == 422 && r.code == 42204, + '自关注被拒 422/42204(${r.json['message']})——库层 ck_user_follows_self 兜底', + '自关注未被拒: ${r.status} ${r.body}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/users/$userIdB/follow', + token: tokenB, + ); + print( + ' DELETE /users/{B}/follow (B 自取关) → ${r.status} ' + '${jsonEncode(r.data)}', + ); + check( + r.status == 200 && r.data['following'] == false, + '自取关 200 幂等 no-op(关系行不可能存在,权威 false 即事实;42204 只在 PUT)', + '自取关不符: ${r.status} ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [10/14] 验收标准 ③:Feed 游标分页不丢不重 + // ================================================================ + print('[10/14] 【验收③】A 批量发 $bulkPostCount 帖 → 双粒度全量翻页比对'); + final bulkIds = []; + for (var i = 1; i <= bulkPostCount; i++) { + r = await createPost(tokenA, { + 'title': 'M3 分页帖 #$i', + 'content': '分页取证第 $i 帖(ts=$ts)', + 'category': i.isEven ? 'help' : 'general', + 'status': 'published', + }); + if (r.status != 201) fail('第 $i 帖创建失败: ${r.status} ${r.body}'); + bulkIds.add(r.data['id'] as String); + } + print(' POST /api/v1/posts ×$bulkPostCount (status=published) → 全部 201'); + check( + bulkIds.toSet().length == bulkPostCount, + '$bulkPostCount 帖全部创建成功且 id 互不相同', + 'id 有重复: ${bulkIds.length} vs ${bulkIds.toSet().length}', + ); + + final (idsSmall, pagesSmall) = await drainFeed(tokenB, 7); + print(' 逐页翻到底(limit=7)→ $pagesSmall 页,共 ${idsSmall.length} 条'); + check( + idsSmall.toSet().length == idsSmall.length, + '细粒度翻页零重复(${idsSmall.length} 条全唯一)', + '细粒度翻页出现重复 id:' + '${idsSmall.length - idsSmall.toSet().length} 个', + ); + check( + pagesSmall >= 4, + '翻页确实跨多页($pagesSmall 页 > 1,游标真被使用)', + '页数过少无法证明翻页: $pagesSmall', + ); + + final (idsLarge, pagesLarge) = await drainFeed(tokenB, 100); + print(' 逐页翻到底(limit=100)→ $pagesLarge 页,共 ${idsLarge.length} 条'); + check( + idsLarge.toSet().length == idsLarge.length, + '粗粒度翻页零重复(${idsLarge.length} 条全唯一)', + '粗粒度翻页出现重复 id', + ); + check( + idsSmall.length == idsLarge.length && + const ListEquality().equals(idsSmall, idsLarge), + '两种页大小全量结果**逐位一致**(顺序与集合都相同)→ 翻页不丢不重', + '两种粒度结果不一致:limit=7 得 ${idsSmall.length} 条 / ' + 'limit=100 得 ${idsLarge.length} 条;' + '差集 ${idsSmall.toSet().difference(idsLarge.toSet()).length}/' + '${idsLarge.toSet().difference(idsSmall.toSet()).length}', + ); + final missing = bulkIds.where((id) => !idsSmall.contains(id)).toList(); + check( + missing.isEmpty, + '$bulkPostCount 帖 + 主贴全部恰好出现一次(无遗漏)', + '遗漏 ${missing.length} 帖: $missing', + ); + check(idsSmall.contains(postId), '主贴(场景 3 发布)亦在全量结果内', '主贴不在 Feed 全量结果内'); + print( + ' Feed 全量条数(含既有数据):${idsSmall.length};' + '本轮新增 ${bulkPostCount + 1} 条', + ); + _passed++; + print(''); + + // ================================================================ + // [11/14] 验收标准 ④:软删帖不再出现在公共 Feed + // ================================================================ + print('[11/14] 【验收④】A 软删一帖 → B 的 Feed 不再含该帖,直接 GET 404/40403'); + r = await createPost(tokenA, { + 'title': 'M3 待删帖', + 'content': '本帖将被软删,用于验收标准 ④(ts=$ts)', + 'status': 'published', + }); + check(r.status == 201, '待删帖创建并发布成功(201)', '待删帖创建失败: ${r.body}'); + final doomedId = r.data['id'] as String; + print(' doomedPostId: $doomedId'); + + final (idsBefore, _) = await drainFeed(tokenB, 50); + check( + idsBefore.first == doomedId, + '删除前:该帖在 B 的 Feed 首位(全量 ${idsBefore.length} 条)', + '删除前该帖不在 Feed 首位: ${idsBefore.first}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/posts/$doomedId', + token: tokenA, + ); + print(' DELETE /api/v1/posts/$doomedId (A 软删) → ${r.status}'); + check( + r.status == 200 && r.code == 0, + '软删成功(deleted_at 写入)', + '软删失败: ${r.body}', + ); + + final (idsAfter, _) = await drainFeed(tokenB, 50); + print(' B 全量翻 Feed(删除后)→ ${idsAfter.length} 条'); + check( + !idsAfter.contains(doomedId) && idsAfter.length == idsBefore.length - 1, + '删除后 B 的 Feed 全量不含该帖,且总数恰少 1(其余帖不受影响)', + '软删帖仍在 Feed 或总数异常:' + 'before ${idsBefore.length} / after ${idsAfter.length}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/posts/$doomedId', + token: tokenB, + ); + print( + ' GET /api/v1/posts/$doomedId (B 直接访问) → ${r.status} / ' + 'code ${r.code}', + ); + check( + r.status == 404 && r.code == 40403, + 'B 直接 GET 已删帖 404/40403(${r.json['message']})', + '已删帖仍可读: ${r.status} ${r.body}', + ); + final deletedBodyByB = r.body; + + r = await call( + 'GET', + '$communityUrl/api/v1/posts/$doomedId', + token: tokenA, + ); + check( + r.status == 404 && r.code == 40403 && r.body == deletedBodyByB, + '作者 A 自己 GET 已删帖同样 404/40403(响应体与 B 逐字节一致)', + '作者侧已删帖响应不一致: ${r.status} ${r.body}', + ); + + r = await call( + 'DELETE', + '$communityUrl/api/v1/posts/$doomedId', + token: tokenA, + ); + check( + r.status == 404 && r.code == 40403, + '重复删除与删不存在的帖同响应 404/40403(防枚举合并)', + '重复删除响应不符: ${r.status} ${r.body}', + ); + + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$doomedId/like', + token: tokenB, + ); + check( + r.status == 404 && r.code == 40403, + '已删帖的互动面同样 404/40403(删除后一切路径关闭)', + '已删帖仍可点赞: ${r.status} ${r.body}', + ); + _passed++; + print(''); + + // ================================================================ + // [12/14] 防枚举一致性:B 访问 A 的草稿 vs 随机 UUID + // ================================================================ + print('[12/14] 防枚举:B 访问 A 的草稿 与 访问随机 UUID → 响应体逐字节一致'); + r = await createPost(tokenA, { + 'title': 'M3 私密草稿', + 'content': '仅作者可见的草稿,用于防枚举核对(ts=$ts)', + 'status': 'draft', + }); + check( + r.status == 201 && r.data['status'] == 'draft', + 'A 的草稿创建成功', + '草稿创建失败: ${r.body}', + ); + final secretDraftId = r.data['id'] as String; + final randomId = uuidV4(); + print(' A 的草稿 id: $secretDraftId'); + print(' 随机 UUID: $randomId'); + + final probeRoutes = { + '详情 GET /posts/{草稿}': '$communityUrl/api/v1/posts/$secretDraftId', + '详情 GET /posts/{随机}': '$communityUrl/api/v1/posts/$randomId', + '评论 GET /posts/{草稿}/comments': + '$communityUrl/api/v1/posts/$secretDraftId/comments', + '评论 GET /posts/{随机}/comments': + '$communityUrl/api/v1/posts/$randomId/comments', + }; + final probeBodies = []; + for (final entry in probeRoutes.entries) { + r = await call('GET', entry.value, token: tokenB); + print(' ${entry.key} → ${r.status} / code ${r.code}'); + check( + r.status == 404 && r.code == 40403, + '404/40403(${entry.key})', + '${entry.key} 未按防枚举拒绝: ${r.status} ${r.body}', + ); + probeBodies.add(r.body); + } + check( + probeBodies.toSet().length == 1, + '四路响应体完全一致(防枚举):${probeBodies.first}', + '响应体不一致,可区分真实草稿与随机 UUID: $probeBodies', + ); + + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$secretDraftId/like', + token: tokenB, + ); + check( + r.status == 404 && r.code == 40403 && r.body == probeBodies.first, + '互动面 = 帖子公开面:草稿点赞同一 404/40403 响应体', + '草稿互动响应不符: ${r.status} ${r.body}', + ); + + r = await call( + 'PUT', + '$communityUrl/api/v1/posts/$secretDraftId/like', + token: tokenA, + ); + check( + r.status == 404 && r.code == 40403, + '**作者本人**对自己草稿的互动亦 404/40403(互动面恒为公开面)', + '作者可对自己草稿互动: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/posts/$secretDraftId', + token: tokenA, + ); + check( + r.status == 200 && r.data['status'] == 'draft', + '草稿对作者本人详情仍可见(draft 仅作者可见)', + '作者读不到自己草稿: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/posts?status=draft&limit=20', + token: tokenA, + ); + check( + r.status == 200 && + (r.data['items'] as List).any( + (e) => (e as Map)['id'] == secretDraftId, + ), + '/me/posts?status=draft 含该草稿(作者视角)', + '我的草稿列表不含该草稿: ${r.body}', + ); + check( + !idsAfter.contains(secretDraftId), + '草稿不在公共 Feed(谓词只放行 published+public+未删)', + '草稿泄露进 Feed', + ); + _passed++; + print(''); + + // ================================================================ + // [13/14] 埋点:v3 社区事件上报(platform=android 模拟真机值) + // ================================================================ + print( + '[13/14] POST /api/v1/events(:8082)上报 v3 社区事件' + '(platform=android 模拟真机值)', + ); + final anonymousId = uuidV4(); + final sessionId = uuidV4(); + final clientTs = DateTime.now().toUtc().toIso8601String(); + Map ev(String name, Map props) => { + 'eventId': uuidV4(), + 'eventName': name, + 'eventVersion': 3, + 'anonymousId': anonymousId, + 'userId': userIdB, + 'sessionId': sessionId, + 'clientTs': clientTs, + 'appVersion': '1.0.0+e2e-m3', + 'platform': 'android', + 'osVersion': 'android-14', + 'props': props, + }; + final events = [ + ev('feed_viewed', { + 'feedTab': 'recommend', + 'durationMs': 8600, + 'impressionCount': 27, + 'loadMoreCount': 3, + 'refreshCount': 1, + }), + ev('post_media_upload_succeeded', { + 'mediaType': 'image', + 'sizeBucket': 'lt_512kb', + 'durationMs': 940, + }), + ev('post_publish_succeeded', { + 'durationMs': 1560, + 'mediaCount': 1, + 'topicCount': 0, + 'textLengthBucket': 'lt_200', + 'fromDraft': true, + }), + ev('post_liked', {'source': 'feed'}), + ev('post_favorited', {'source': 'detail'}), + ev('comment_create_succeeded', { + 'durationMs': 720, + 'isReply': true, + 'textLengthBucket': 'lt_200', + }), + ev('user_followed', {'source': 'detail'}), + ev('page_viewed', {'pageName': 'post_detail', 'referrer': 'home'}), + ]; + for (final e in events) { + print(' eventId: ${e['eventId']} (${e['eventName']})'); + } + r = await call( + 'POST', + '$userUrl/api/v1/events', + token: tokenB, + body: {'events': events}, + ); + print(' POST /api/v1/events (${events.length} 条) → ${r.status}'); + check( + r.status == 202 && r.code == 0, + '批次受理 202', + '埋点上报失败: ${r.status} ${r.body}', + ); + final results = r.data['results'] as List; + final allAccepted = results.every( + (e) => (e as Map)['status'] == 'accepted', + ); + check( + r.data['accepted'] == events.length && + r.data['duplicated'] == 0 && + r.data['rejected'] == 0 && + results.length == events.length && + allAccepted, + '${events.length}/${events.length} 逐条 accepted' + '(accepted=${r.data['accepted']}, duplicated=0, rejected=0)', + '埋点结果不符: ${r.body}', + ); + + // 红线兜底:白名单外的内容 ID 键被剥离后入库(事件保留)。 + final leakEvent = ev('post_liked', {'source': 'feed', 'postId': postId}); + r = await call( + 'POST', + '$userUrl/api/v1/events', + token: tokenB, + body: { + 'events': [leakEvent], + }, + ); + print(' POST /api/v1/events(post_liked 混入白名单外 postId)→ ${r.status}'); + check( + r.status == 202 && + r.data['accepted'] == 1 && + (r.data['results'] as List).single is Map && + ((r.data['results'] as List).single as Map)['status'] == 'accepted', + '白名单外键剥离后事件仍 accepted(隐私红线 ingest 侧兜底,落库无 postId)', + '白名单外键处理不符: ${r.body}', + ); + + // 字典边界:被否决的逐卡曝光事件整条 rejected。 + final rejectedEvent = ev('post_impression', {}); + r = await call( + 'POST', + '$userUrl/api/v1/events', + token: tokenB, + body: { + 'events': [rejectedEvent], + }, + ); + print(' POST /api/v1/events(字典外 post_impression)→ ${r.status}'); + check( + r.status == 202 && + r.data['rejected'] == 1 && + ((r.data['results'] as List).single as Map)['reason'] == + 'unknown_event_name', + '字典外事件整条 rejected(reason=unknown_event_name),批次仍 202', + '字典边界不符: ${r.body}', + ); + + print(' 落库核对(platform.product_events)由报告附 psql 证据。'); + print(' E2E_SESSION_ID=$sessionId'); // 供 psql 查证 + print(' E2E_LEAK_EVENT_ID=${leakEvent['eventId']}'); + _passed++; + print(''); + + // ================================================================ + // [14/14] 幂等重放:同 Idempotency-Key 同 hash / 异 hash + // ================================================================ + print('[14/14] 幂等重放:同 Idempotency-Key 同 hash 返回原帖;异 hash → 409/40905'); + final replayKey = uuidV4(); + final replayBody = { + 'title': 'M3 幂等重放帖', + 'content': '同键同 hash 必须返回同一帖(ts=$ts)', + 'category': 'general', + 'status': 'draft', + }; + r = await createPost(tokenA, replayBody, idempotencyKey: replayKey); + print(' POST /api/v1/posts (Idempotency-Key=, 首发) → ${r.status}'); + check(r.status == 201, '首次创建成功(201)', '首次创建失败: ${r.body}'); + final replayId = r.data['id'] as String; + final replayVersion = r.data['version'] as int; + print(' postId: $replayId / version: $replayVersion'); + + r = await createPost(tokenA, replayBody, idempotencyKey: replayKey); + print(' POST /api/v1/posts (同 KEY-1, 同 hash, 重发) → ${r.status}'); + check( + r.status == 201 && + r.data['id'] == replayId && + r.data['version'] == replayVersion, + '同键同 hash 返回首次结果(id/version 一致),不产生第二帖', + '幂等重放不符: ${r.status} ${r.body}', + ); + + r = await call( + 'GET', + '$communityUrl/api/v1/me/posts?status=draft&limit=100', + token: tokenA, + ); + final myDraftIds = (r.data['items'] as List) + .map((e) => (e as Map)['id'] as String) + .toList(); + check( + myDraftIds.where((id) => id == replayId).length == 1 && + myDraftIds.length == 2, + '我的草稿列表恰 2 条(私密草稿 + 重放帖),重放帖只出现一次(无重复落库)', + '草稿列表异常: $myDraftIds', + ); + + final mismatched = {...replayBody, 'content': '同键但内容不同 → 必须 409/40905'}; + r = await createPost(tokenA, mismatched, idempotencyKey: replayKey); + print( + ' POST /api/v1/posts (同 KEY-1, **异 hash**) → ${r.status} / ' + 'code ${r.code}', + ); + check( + r.status == 409 && r.code == 40905, + '同键异 hash 被拒 409/40905(${r.json['message']})', + '同键异 hash 未被拒: ${r.status} ${r.body}', + ); + + r = await call( + 'POST', + '$communityUrl/api/v1/posts', + token: tokenA, + body: replayBody, + ); + print( + ' POST /api/v1/posts(**不带** Idempotency-Key)→ ${r.status} / ' + 'code ${r.code}', + ); + check( + r.status == 400 && r.code == 40000, + 'Idempotency-Key 缺失被拒 400/40000(${r.json['message']})——该头必带', + '缺头未被拒: ${r.status} ${r.body}', + ); + + r = await createPost(tokenB, replayBody, idempotencyKey: replayKey); + print(' POST /api/v1/posts(B 用同一 KEY-1 值)→ ${r.status}'); + check( + r.status == 201 && r.data['id'] != replayId, + '幂等键按作者隔离:B 用同一键值创建出新帖(id 不同)', + '幂等键跨作者串号: ${r.status} ${r.body}', + ); + _passed++; + print(''); + + print('=== M3 E2E 烟囱测试全部通过 ✓($_passed/14 场景)==='); + print('E2E_USERNAME_A=$usernameA'); + print('E2E_USER_ID_A=$userIdA'); + print('E2E_USER_ID_B=$userIdB'); + print('E2E_POST_ID=$postId'); + print('E2E_ASSET_ID=$assetId'); + print('E2E_DELETED_POST_ID=$doomedId'); + print('E2E_DRAFT_POST_ID=$secretDraftId'); + print('E2E_SESSION_ID=$sessionId'); + } catch (e, stack) { + print('✗ 测试异常: $e'); + print(stack); + exit(1); + } finally { + client.close(); + } +} + +/// 逐元素比较(避免为脚本引入 collection 包依赖)。 +class ListEquality { + const ListEquality(); + bool equals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +}