Files
patbond-flutter/test_e2e_m3_manual.dart
lixi 0e87413360
CI / flutter-gates (push) Successful in 3m9s
test: M3 E2E 烟囱脚本(T3-21 收官)
对 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 <noreply@anthropic.com>
2026-09-10 15:02:48 +08:00

1374 lines
50 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 即输出。
/// 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 创建草稿(:8084Idempotency-Key 必带)→ 引用 ready asset → PATCH 发布
/// 4. A 的帖在 B 的 Feed 首位可见,FeedCard 字段完整(**M3 验收标准 ①**)
/// 5. 预签名 GET 取回图片字节与上传一致
/// 6. B 重复点赞幂等:3 次 PUT → likeCount 恰为 1DELETE → 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. 软删帖出 FeedA 软删一帖 → 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 JPEG344 字节),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))}...<REDACTED>';
/// 预签名 URL 脱敏:保留 host + 对象路径,签名 query 整体抹掉。
String redactSignedUrl(String url) {
final uri = Uri.parse(url);
return '${uri.scheme}://${uri.host}:${uri.port}${uri.path}'
'?<SIGNATURE_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);
Map<String, dynamic> get data => json['data'] as Map<String, dynamic>;
int? get code => json['code'] as int?;
}
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);
}
/// 预签名 PUT 直传:不带 Bearer(鉴权即 URL 签名本身),requiredHeaders 原样携带,
/// contentLength 显式设置(与 lib/features/community/media_direct_upload.dart 同构)。
Future<int> presignedPut(
String url,
Map<String, String> 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<void>();
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 = <int>[];
await resp.forEach(chunks.addAll);
return (resp.statusCode, Uint8List.fromList(chunks));
}
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)}';
}
/// 全量翻页 Feed:返回 (有序 id 列表, 页数)。
/// 页上限兜底防死循环(游标语义破裂时立即暴露而非挂死)。
Future<(List<String>, int)> drainFeed(String token, int limit) async {
final ids = <String>[];
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<Resp> createPost(
String token,
Map<String, dynamic> 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/jpegsha256=$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 <presigned>${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→readybyteSize=${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=publishedpublishedAt 已写,'
'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<String, dynamic>;
final cardAuthor = card['author'] as Map<String, dynamic>;
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<String, dynamic>?;
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 <presigned> → $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=13 次 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<String, dynamic>;
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、无 updatedAtM3 无编辑)',
'评论 #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 关注 APUT+ 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 / 关注 0followedByMe=trueB 视角)',
'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 = <String>[];
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 = <String, String>{
'详情 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 = <String>[];
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<String, dynamic> ev(String name, Map<String, dynamic> 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/eventspost_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',
'字典外事件整条 rejectedreason=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 = <String, dynamic>{
'title': 'M3 幂等重放帖',
'content': '同键同 hash 必须返回同一帖(ts=$ts',
'category': 'general',
'status': 'draft',
};
r = await createPost(tokenA, replayBody, idempotencyKey: replayKey);
print(' POST /api/v1/posts (Idempotency-Key=<KEY-1>, 首发) → ${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/postsB 用同一 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<T>(List<T> a, List<T> 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;
}
}