0e87413360
CI / flutter-gates (push) Successful in 3m9s
对 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>
1374 lines
50 KiB
Dart
1374 lines
50 KiB
Dart
#!/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))}...<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/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 <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→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<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=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<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、无 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 = <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/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 = <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/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<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;
|
||
}
|
||
}
|