#!/usr/bin/env dart // ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。 /// M3.5 E2E 烟囱测试脚本(v0.4.0 发布门禁):对 compose 真实后端跑通 /// 「用户资料读写 + 用户/宠物头像 + 获赞聚合」三条 M3.5 新增对外面。 /// /// 前置条件:patbond-api 目录执行 `docker compose up -d --build`(六容器: /// postgres + minio + auth:8081 + user:8082 + pet:8083 + community:8084) /// 运行方式:dart run test_e2e_m35_manual.dart /// /// 与 test_e2e_manual.dart(M1,7 场景)/ test_e2e_m2_manual.dart(M2,11 场景) /// / test_e2e_m3_manual.dart(M3,14 场景)并列——本脚本**只覆盖 M3.5 增量面**, /// 回归由前三份承担。冻结契约 openapi **v1.4.0**(32 路径 / 45 操作 / 75 schema)。 /// /// 覆盖 10 个场景: /// 1. 注册 A、B → `GET /me` 初始形态:nickname/avatarUrl 双 null、**不外露** /// avatarAssetId、Me 恰 6 字段 /// 2. media `purpose` 白名单三值放行(post_image / user_avatar / pet_avatar), /// 白名单外(id_card)创建上传 400/40000 /// 3. `PATCH /me` 设昵称 → 响应回显与 `GET /me` 回读逐字段一致(同一 Me 形态) /// 4. 用户头像端到端:两步上传(user_avatar) → `PATCH /me` 挂载 → avatarUrl /// 预签名 GET **逐字节一致** → 响应不含 avatarAssetId /// 5. `PATCH /me` **三态**:只带 nickname 不动头像 / 显式 null 清空昵称(头像留存) /// / 显式 null 清除头像 / 空 patch 400 / 纯空白 400 / 空串 400 / /// 只带未声明字段 400 / 非法 UUID 400 / 同 body 重放幂等 /// 6. 昵称边界(长度按**码点**计):1 可 / 32 CJK 可 / **32 emoji 可** / /// 33 CJK 拒 / 33 emoji 拒 / btrim 对齐 /// 7. 用户头像 asset 四态校验:幽灵 / 他人(B) / 错用途(post_image、pet_avatar) /// → 404/40405;本人用途相符但 uploading → 422/42203 /// 8. 宠物头像:建档初始 avatarUrl=null 且不外露 avatarAssetId → `PATCH /pets/{id}` /// 挂 pet_avatar → **详情与列表两处** avatarUrl 均可下载 → 显式 null 清除 /// 9. 宠物头像 asset 校验:错用途(user_avatar、post_image) / 幽灵 → 404/40405; /// uploading → 422/42203;version 仍必填(缺失 400/40000) /// 10. `GET /me/community-stats`:无 token 401/40101 → 空数据 0/0(永不 404)→ /// 发布 + **自赞** → 1/1 → 草稿不计 → 第二帖 → 2 → 软删该帖 → 获赞归零 /// /// 敏感信息脱敏:token 截断、预签名 URL 签名 query 整体抹为 ``、 /// Idempotency-Key 占位。 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(/me、media 两步上传) const petUrl = 'http://127.0.0.1:8083'; // patbond-pet const communityUrl = 'http://127.0.0.1:8084'; // patbond-community /// 固定测试图:1×1 JPEG(344 字节)。与 M3 脚本同一常量,sha256 见下。 /// complete 仅 HEAD 校验 byteSize/Content-Type,但用真 JPEG 让宽高回填与 /// 预签名 GET 字节比对都走真路径。 const testJpegBase64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof' 'Gh0aHBwcJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPDIzNP/AABEIAAEAAQMBIgACEQEDEQH/' 'xAAfAAABBQEBAQEBAQAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQEC' 'AwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5' 'OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Sl' 'pqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/a' 'AAwDAQACEQMRAD8A9/ooooA//9k='; /// 上方常量解码后的 sha256(契约照收照存,不做内容核验)。 const testJpegSha256 = '32142d9c5636535cf882b05c03275d2b58150623df750b059db0bbdab6973ce8'; /// `Me` 冻结形态的键集合(v1.4.0:恰 6 字段,见 components.schemas.Me)。 const meKeys = { 'userId', 'username', 'nickname', 'phone', 'avatarUrl', 'createdAt', }; 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}' '?'; } /// 昵称脱敏无必要,但超长昵称按码点数概述以保持输出可读。 String describeNickname(String s) => '"${s.runes.length <= 8 ? s : '${String.fromCharCodes(s.runes.take(4))}…'}"' '(${s.runes.length} 码点 / UTF-16 长度 ${s.length})'; void fail(String msg) { print(' ✗ $msg'); client.close(); exit(1); } void check(bool cond, String okMsg, String failMsg) { if (cond) { print(' ✓ $okMsg'); } else { fail(failMsg); } } class Resp { final int status; final String body; final Map json; Resp(this.status, this.body, this.json); Map get data => json['data'] as Map; List get list => json['data'] as List; int? get code => json['code'] as int?; String? get message => json['message'] as String?; } /// 通用调用。[rawBody] 用于绕过 jsonEncode 直发原文(坏 JSON 等场景)。 Future call( String method, String url, { String? token, Object? body, String? rawBody, Map? headers, }) async { final req = await client.openUrl(method, Uri.parse(url)); if (body != null || rawBody != null) { req.headers.contentType = ContentType.json; } if (token != null) req.headers.set('Authorization', 'Bearer $token'); headers?.forEach(req.headers.set); if (rawBody != null) { req.write(rawBody); } else 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 原样携带。 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)}'; } final _imageBytes = base64Decode(testJpegBase64); /// 两步上传:createUpload → (可选)预签名 PUT 直传 → (可选)complete 置 ready。 /// [complete] 为 false 时 asset 停在 `uploading`(供 422/42203 取证)。 Future uploadAsset( String token, String purpose, { bool complete = true, String label = '', }) async { var r = await call( 'POST', '$userUrl/api/v1/media/uploads', token: token, body: { 'kind': 'image', 'purpose': purpose, 'mimeType': 'image/jpeg', 'byteSize': _imageBytes.length, 'sha256': testJpegSha256, }, ); if (r.status != 201) { fail('createUpload($purpose$label) 失败: ${r.status} ${r.body}'); } final assetId = r.data['assetId'] as String; if (!complete) { print(' asset($purpose$label, 停在 uploading): $assetId'); return assetId; } final uploadUrl = r.data['uploadUrl'] as String; final requiredHeaders = (r.data['requiredHeaders'] as Map).map( (k, v) => MapEntry(k as String, v as String), ); final putStatus = await presignedPut(uploadUrl, requiredHeaders, _imageBytes); if (putStatus != 200) fail('直传失败($purpose$label): HTTP $putStatus'); r = await call( 'POST', '$userUrl/api/v1/media/uploads/$assetId/complete', token: token, ); if (r.status != 200 || r.data['status'] != 'ready') { fail('complete($purpose$label) 失败: ${r.status} ${r.body}'); } print(' asset($purpose$label, ready): $assetId'); return assetId; } /// 创建帖子(Idempotency-Key 必带;日志里以占位符示意)。 Future createPost(String token, Map body) => call( 'POST', '$communityUrl/api/v1/posts', token: token, body: body, headers: {'Idempotency-Key': uuidV4()}, ); /// 断言 Me 形态:键集合恰为冻结的 6 键,且**不外露** avatarAssetId。 void assertMeShape(Map me, String where) { check( !me.containsKey('avatarAssetId'), '$where:响应**不含** avatarAssetId(只写不读,"是否有头像"等价于 avatarUrl != null)', '$where 外露了 avatarAssetId: ${me.keys}', ); check( me.keys.toSet().difference(meKeys).isEmpty && me.containsKey('userId') && me.containsKey('username') && me.containsKey('nickname') && me.containsKey('avatarUrl') && me.containsKey('createdAt'), '$where:Me 形态在冻结的 6 字段内,nickname/avatarUrl 键恒在(值可 null)', '$where Me 形态偏离契约: ${me.keys}', ); } Future patchMe(String token, Object body) => call('PATCH', '$userUrl/api/v1/me', token: token, body: body); Future getMe(String token) => call('GET', '$userUrl/api/v1/me', token: token); Future patchPet(String token, String petId, Map body) => call('PATCH', '$petUrl/api/v1/pets/$petId', token: token, body: body); void main() async { final ts = DateTime.now().millisecondsSinceEpoch; final usernameA = 'e2e_m35_a_$ts'; final usernameB = 'e2e_m35_b_$ts'; const password = 'Test@123456'; final phoneA = '+8613${Random().nextInt(900000000) + 100000000}'; final phoneB = '+8613${Random().nextInt(900000000) + 100000000}'; print('=== Patbond M3.5 E2E 烟囱测试开始(v0.4.0 发布门禁)==='); print('契约:openapi v1.4.0;只覆盖 M3.5 增量面(回归由 M1/M2/M3 三份承担)'); print('账号 A: $usernameA'); print('账号 B: $usernameB'); print('测试图: ${_imageBytes.length} 字节 image/jpeg,sha256=$testJpegSha256'); print(''); try { // ================================================================ // [1/10] 注册 A、B → GET /me 初始形态(nickname/avatarUrl 双 null) // ================================================================ print('[1/10] 注册 A、B(:8081)→ GET /api/v1/me 初始形态'); 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 / 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 / accessToken(B): ${redact(tokenB)}'); r = await getMe(tokenA); print(' GET /api/v1/me (A, 全新账号) → ${r.status}'); check( r.status == 200 && r.code == 0, 'GET /me 返回 200', 'GET /me 失败: ${r.body}', ); var me = r.data; print( ' userId=${me['userId']} username=${me['username']} ' 'nickname=${me['nickname']} avatarUrl=${me['avatarUrl']} ' 'phone=${me['phone']}', ); check( me.containsKey('nickname') && me['nickname'] == null, 'nickname 键恒在且为 **null**(未设置;/me 不做 username 回退——注册不收昵称)', 'nickname 初始值不符(期望 null): ${me['nickname']}', ); check( me.containsKey('avatarUrl') && me['avatarUrl'] == null, 'avatarUrl 键恒在且为 **null**(无头像)', 'avatarUrl 初始值不符(期望 null): ${me['avatarUrl']}', ); check( me['userId'] == userIdA && me['username'] == usernameA, 'userId/username 与注册一致(既有字段未回归)', 'userId/username 不符: ${r.body}', ); assertMeShape(me, 'GET /me 初始态'); _passed++; print(''); // ================================================================ // [2/10] media purpose 白名单:三值放行;白名单外 id_card 被拒 // ================================================================ print('[2/10] media `purpose` 白名单:三值放行(含 M3.5 新增两值);id_card 被拒'); final assetUserAvatar = await uploadAsset( tokenA, 'user_avatar', label: ', A 的用户头像', ); final assetPetAvatar = await uploadAsset( tokenA, 'pet_avatar', label: ', A 的宠物头像', ); final assetPostImage = await uploadAsset( tokenA, 'post_image', label: ', A 的帖子配图', ); check( {assetUserAvatar, assetPetAvatar, assetPostImage}.length == 3, 'post_image / **user_avatar** / **pet_avatar** 三用途均登记并置 ready 成功', '三用途 assetId 有重复', ); r = await call( 'POST', '$userUrl/api/v1/media/uploads', token: tokenA, body: { 'kind': 'image', 'purpose': 'id_card', 'mimeType': 'image/jpeg', 'byteSize': _imageBytes.length, 'sha256': testJpegSha256, }, ); print( ' POST /api/v1/media/uploads (purpose=id_card, 白名单外) → ${r.status} / code ${r.code}', ); check( r.status == 400 && r.code == 40000, '白名单外 purpose 创建上传被拒 400/40000(${r.message})——白名单是引用侧的类型检查', '白名单外 purpose 未被拒: ${r.status} ${r.body}', ); _passed++; print(''); // ================================================================ // [3/10] PATCH /me 设昵称 → 响应回显与 GET 回读逐字段一致 // ================================================================ print('[3/10] PATCH /api/v1/me 设昵称 → 响应回显与 GET /me 回读一致'); const nick1 = '柴柴小当家'; r = await patchMe(tokenA, {'nickname': nick1}); print(' PATCH /api/v1/me {nickname: "$nick1"} → ${r.status}'); check( r.status == 200 && r.code == 0, 'PATCH 成功 200(无 version 乐观锁、无 Idempotency-Key)', 'PATCH 设昵称失败: ${r.status} ${r.body}', ); final patchEcho = r.data; check( patchEcho['nickname'] == nick1, 'PATCH 响应回显新昵称(返回与 GET 完全相同的 Me 全量形态)', 'PATCH 回显昵称不符: ${patchEcho['nickname']}', ); assertMeShape(patchEcho, 'PATCH /me 响应'); r = await getMe(tokenA); me = r.data; print(' GET /api/v1/me(回读)→ ${r.status} nickname=${me['nickname']}'); check( r.status == 200 && me['nickname'] == nick1, '回读昵称与写入一致(持久化生效)', '回读昵称不符: ${me['nickname']}', ); check( me['userId'] == patchEcho['userId'] && me['username'] == patchEcho['username'] && me['createdAt'] == patchEcho['createdAt'] && me['phone'] == patchEcho['phone'] && me['avatarUrl'] == patchEcho['avatarUrl'], 'PATCH 回显与 GET 回读**逐字段一致**(两动词同一 Me 形态)', 'PATCH 回显与 GET 回读不一致: ${jsonEncode(patchEcho)} vs ${jsonEncode(me)}', ); _passed++; print(''); // ================================================================ // [4/10] 用户头像端到端:挂载 → avatarUrl 预签名 GET 逐字节一致 // ================================================================ print('[4/10] 用户头像端到端:PATCH /me 挂 user_avatar → avatarUrl 下载逐字节比对'); r = await patchMe(tokenA, {'avatarAssetId': assetUserAvatar}); print(' PATCH /api/v1/me {avatarAssetId: $assetUserAvatar} → ${r.status}'); check( r.status == 200 && r.code == 0, '挂载用户头像成功 200', '挂载用户头像失败: ${r.status} ${r.body}', ); me = r.data; var avatarUrl = me['avatarUrl'] as String?; check( avatarUrl != null, 'avatarUrl 由 null 转为**现签预签名 GET**(asset 为 ready 才签)', '挂载后 avatarUrl 仍为 null: ${r.body}', ); print(' avatarUrl: ${redactSignedUrl(avatarUrl!)}'); check( Uri.parse(avatarUrl).queryParameters.containsKey('X-Amz-Signature'), 'avatarUrl 携带 SigV4 query 签名族(私有桶靠签名,不经应用服务器)', 'avatarUrl 无 X-Amz-Signature: ${redactSignedUrl(avatarUrl)}', ); check( me['nickname'] == nick1, '只带 avatarAssetId 的 patch **未动昵称**(三态:键缺省 = 不改)', '挂头像时昵称被改动: ${me['nickname']}', ); assertMeShape(me, 'PATCH /me 挂头像后'); var (getStatus, downloaded) = await presignedGet(avatarUrl); 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}', ); // 每次响应现签:GET /me 再取一次仍可下载(且不缓存 URL)。 r = await getMe(tokenA); final avatarUrl2 = r.data['avatarUrl'] as String; final (getStatus2, downloaded2) = await presignedGet(avatarUrl2); check( getStatus2 == 200 && downloaded2.length == _imageBytes.length, 'GET /me 的 avatarUrl 亦为现签且可下载(每次响应现签,客户端不得持久化)', 'GET /me 的 avatarUrl 不可下载: HTTP $getStatus2', ); final unsigned = Uri.parse(avatarUrl).replace(query: '').toString(); final (unsignedStatus, _) = await presignedGet(unsigned); print(' GET <同一对象但去掉签名> → $unsignedStatus'); check( unsignedStatus == 403, '桶保持私有:无签名直访头像对象被存储侧拒绝(403)', '无签名直访未被拒: HTTP $unsignedStatus', ); _passed++; print(''); // ================================================================ // [5/10] PATCH /me 三态语义 + 参数错谱 + 重放幂等 // ================================================================ print('[5/10] PATCH /api/v1/me **三态**:键缺省 / 显式 null / 给值;及参数错谱'); const nick2 = '豆豆'; r = await patchMe(tokenA, {'nickname': nick2}); print(' PATCH {nickname: "$nick2"}(只带昵称)→ ${r.status}'); check( r.status == 200 && r.data['nickname'] == nick2 && r.data['avatarUrl'] != null, '**只带 nickname 的 patch 不影响头像**(avatarUrl 仍非 null)——键缺省 = 不改', '只带昵称的 patch 影响了头像: ${r.body}', ); r = await patchMe(tokenA, {'nickname': null}); print(' PATCH {nickname: null}(显式 null)→ ${r.status}'); check( r.status == 200 && r.data['nickname'] == null && r.data['avatarUrl'] != null, '**显式 null 清空昵称生效**,且头像留存(清空只作用于携带的键)', '显式 null 未清空昵称或误清头像: ${r.body}', ); r = await getMe(tokenA); check( r.data['nickname'] == null, '回读确认昵称已为 null(清空已持久化)', '清空后回读昵称非 null: ${r.data['nickname']}', ); r = await patchMe(tokenA, {'avatarAssetId': null}); print(' PATCH {avatarAssetId: null}(显式 null)→ ${r.status}'); check( r.status == 200 && r.data['avatarUrl'] == null, '**显式 null 清除头像生效**(avatarUrl 回落 null)', '显式 null 未清除头像: ${r.body}', ); assertMeShape(r.data, 'PATCH /me 清空后'); r = await patchMe(tokenA, {}); print(' PATCH {}(空 patch)→ ${r.status} / code ${r.code}'); check( r.status == 400 && r.code == 40000, '**空 patch 400/40000**(${r.message})——不静默 200,空 PATCH 几乎总是客户端 bug', '空 patch 未被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'nickname': ' '}); print(' PATCH {nickname: " "}(纯空白)→ ${r.status} / code ${r.code}'); check( r.status == 400 && r.code == 40000, '**纯空白昵称 400/40000**(${r.message})——不隐式清空,清空只留显式 null 一条路', '纯空白昵称未被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'nickname': ''}); print(' PATCH {nickname: ""}(空串)→ ${r.status} / code ${r.code}'); check( r.status == 400 && r.code == 40000, '空串昵称 400/40000(与纯空白同判)', '空串昵称未被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'bio': '只带未声明字段'}); print(' PATCH {bio: …}(只带未声明字段)→ ${r.status} / code ${r.code}'); check( r.status == 400 && r.code == 40000, '只带未声明字段 = 空 patch,400/40000(bio 列存在但 M3.5 未开放读写)', '只带未声明字段未被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'avatarAssetId': 'not-a-uuid'}); print( ' PATCH {avatarAssetId: "not-a-uuid"} → ${r.status} / code ${r.code}', ); check( r.status == 400 && r.code == 40000, '非法 UUID 的 avatarAssetId 400/40000(参数错先于引用校验)', '非法 UUID 未被拒: ${r.status} ${r.body}', ); r = await call( 'PATCH', '$userUrl/api/v1/me', token: tokenA, rawBody: '{"nickname": ', ); print(' PATCH <坏 JSON> → ${r.status} / code ${r.code}'); check( r.status == 400 && r.code == 40000, '非法 JSON body 400/40000', '坏 JSON 未被拒: ${r.status} ${r.body}', ); r = await call( 'PATCH', '$userUrl/api/v1/me', body: {'nickname': '无 token'}, ); print(' PATCH(无 token)→ ${r.status} / code ${r.code}'); check( r.status == 401 && r.code == 40101, '无 token 401/40101(写入口同样强制鉴权)', '无 token 未被拒: ${r.status} ${r.body}', ); // 重放幂等:同 body 连发两次,第二次仍 200 且状态与首次一致。 final replayBody = {'nickname': '重放昵称', 'avatarAssetId': assetUserAvatar}; r = await patchMe(tokenA, replayBody); check(r.status == 200, '重放取证:首次 PATCH 成功 200', '重放首发失败: ${r.body}'); final firstNick = r.data['nickname']; final firstHasAvatar = r.data['avatarUrl'] != null; r = await patchMe(tokenA, replayBody); print(' PATCH(同 body 重放第二次)→ ${r.status}'); check( r.status == 200 && r.data['nickname'] == firstNick && (r.data['avatarUrl'] != null) == firstHasAvatar, '同 body 重放第二次仍 200 且状态与首次一致(无 Idempotency-Key,天然幂等)', '重放结果不一致: ${r.body}', ); _passed++; print(''); // ================================================================ // [6/10] 昵称边界:长度按码点计(1 / 32 CJK / 32 emoji / 33 拒) // ================================================================ print('[6/10] 昵称边界(长度按**码点**计,btrim 后 1~32)'); final nick1cp = '柴'; final nick32cjk = '猫' * 32; final nick33cjk = '猫' * 33; final nick32emoji = '🐕' * 32; // 32 码点 / UTF-16 长度 64 final nick33emoji = '🐕' * 33; // 33 码点 / UTF-16 长度 66 r = await patchMe(tokenA, {'nickname': nick1cp}); print(' PATCH ${describeNickname(nick1cp)} → ${r.status}'); check( r.status == 200 && r.data['nickname'] == nick1cp, '**1 码点**昵称接受(下边界)', '1 码点昵称被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'nickname': nick32cjk}); print(' PATCH ${describeNickname(nick32cjk)} → ${r.status}'); check( r.status == 200 && r.data['nickname'] == nick32cjk, '**32 CJK 码点**昵称接受(上边界)', '32 CJK 昵称被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'nickname': nick32emoji}); print(' PATCH ${describeNickname(nick32emoji)} → ${r.status}'); check( r.status == 200 && r.data['nickname'] == nick32emoji, '**32 emoji 码点**昵称接受(UTF-16 长度 64 却过关 → 长度确按码点计,' '非 String.length())', '32 emoji 昵称被拒(疑按 UTF-16 长度校验): ${r.status} ${r.body}', ); r = await getMe(tokenA); check( r.data['nickname'] == nick32emoji && (r.data['nickname'] as String).runes.length == 32, '回读 32 emoji 昵称逐字一致(存储与返回都无截断)', 'emoji 昵称回读不符: ${r.data['nickname']}', ); r = await patchMe(tokenA, {'nickname': nick33cjk}); print( ' PATCH ${describeNickname(nick33cjk)} → ${r.status} / code ${r.code}', ); check( r.status == 400 && r.code == 40000, '**33 CJK 码点**昵称被拒 400/40000(${r.message})', '33 CJK 昵称未被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'nickname': nick33emoji}); print( ' PATCH ${describeNickname(nick33emoji)} → ${r.status} / code ${r.code}', ); check( r.status == 400 && r.code == 40000, '**33 emoji 码点**昵称被拒 400/40000(上边界对两种字符集一致)', '33 emoji 昵称未被拒: ${r.status} ${r.body}', ); r = await patchMe(tokenA, {'nickname': ' 豆豆 '}); print(' PATCH {nickname: " 豆豆 "}(前后空白)→ ${r.status}'); check( r.status == 200 && r.data['nickname'] == '豆豆', 'btrim 对齐:" 豆豆 " 存为 "豆豆"(长度校验在 btrim 之后)', 'btrim 未对齐: ${r.data['nickname']}', ); _passed++; print(''); // ================================================================ // [7/10] 用户头像 asset 四态校验:40405 五路 + 42203 // ================================================================ print('[7/10] 用户头像 asset 校验:幽灵/他人/错用途 → 404/40405;uploading → 422/42203'); final ghostAssetId = uuidV4(); final assetOfB = await uploadAsset(tokenB, 'user_avatar', label: ', B 的头像'); final notFoundCases = { '幽灵 assetId': ghostAssetId, '他人(B)的 user_avatar': assetOfB, '错用途:post_image': assetPostImage, '错用途:pet_avatar(跨域头像不通用)': assetPetAvatar, }; final notFoundBodies = []; for (final entry in notFoundCases.entries) { r = await patchMe(tokenA, {'avatarAssetId': entry.value}); print(' PATCH /me ← ${entry.key} → ${r.status} / code ${r.code}'); check( r.status == 404 && r.code == 40405, '${entry.key} → 404/40405(${r.message})', '${entry.key} 未按 40405 拒绝: ${r.status} ${r.body}', ); notFoundBodies.add(r.body); } check( notFoundBodies.take(2).toSet().length == 1, '幽灵 id 与他人 asset 响应体逐字节一致(防枚举合并:不能据差异探出 id 是真的)', '幽灵与他人 asset 响应可区分: $notFoundBodies', ); final assetUploading = await uploadAsset( tokenA, 'user_avatar', complete: false, label: ', 真未直传', ); r = await patchMe(tokenA, {'avatarAssetId': assetUploading}); print( ' PATCH /me ← 本人 user_avatar 但仍 uploading → ${r.status} / code ${r.code}', ); check( r.status == 422 && r.code == 42203, '本人、用途相符但未就绪 → 422/42203(${r.message})——状态机拒绝,可重试', '未就绪 asset 未按 42203 拒绝: ${r.status} ${r.body}', ); r = await getMe(tokenA); check( r.status == 200 && r.data['avatarUrl'] != null, '以上 5 次失败的 PATCH **均未产生副作用**(头像仍是场景 5 重放时挂上的那张)', '失败的 PATCH 改动了头像: ${r.body}', ); _passed++; print(''); // ================================================================ // [8/10] 宠物头像:详情与列表两处 avatarUrl + 三态清除 // ================================================================ print('[8/10] 宠物头像:建档 → PATCH 挂 pet_avatar → 详情+列表 avatarUrl → 清除'); r = await call( 'POST', '$petUrl/api/v1/pets', token: tokenA, body: { 'name': 'M3.5 头像喵', 'species': 'cat', 'sex': 'female', 'customBreedName': '中华田园猫', }, ); print(' POST /api/v1/pets → ${r.status}'); check( r.status == 201 && r.code == 0, '建档成功(201)', '建档失败: ${r.status} ${r.body}', ); final petId = r.data['id'] as String; var petVersion = r.data['version'] as int; print(' petId: $petId / version: $petVersion'); check( r.data.containsKey('avatarUrl') && r.data['avatarUrl'] == null, '创建响应 avatarUrl 键恒在且为 null(新建无头像)', '创建响应 avatarUrl 不符: ${r.data['avatarUrl']}', ); check( !r.data.containsKey('avatarAssetId'), 'Pet 响应**不含** avatarAssetId(与 Me 同一纪律:只写不读)', 'Pet 响应外露 avatarAssetId: ${r.data.keys}', ); r = await patchPet(tokenA, petId, { 'version': petVersion, 'avatarAssetId': assetPetAvatar, }); print(' PATCH /api/v1/pets/$petId {version, avatarAssetId} → ${r.status}'); check( r.status == 200 && r.code == 0, '挂宠物头像成功 200(仅头像字段 → WRITE 档)', '挂宠物头像失败: ${r.status} ${r.body}', ); check( r.data['version'] == petVersion + 1, 'version $petVersion→${r.data['version']}(头像与资料共用同一把乐观锁)', 'version 未 +1: ${r.data['version']}', ); petVersion = r.data['version'] as int; final petAvatarUrl = r.data['avatarUrl'] as String?; check( petAvatarUrl != null, 'PATCH 响应 avatarUrl 现签非空', 'PATCH 响应 avatarUrl 为 null: ${r.body}', ); print(' avatarUrl (PATCH 响应): ${redactSignedUrl(petAvatarUrl!)}'); check( !r.data.containsKey('avatarAssetId'), 'PATCH 响应仍不外露 avatarAssetId', 'PATCH 响应外露 avatarAssetId: ${r.data.keys}', ); check( r.data['name'] == 'M3.5 头像喵' && r.data['species'] == 'cat', '只带 avatarAssetId 的 patch 未动资料字段(name/species 原样)', '资料字段被改动: ${r.body}', ); r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); final detailUrl = r.data['avatarUrl'] as String?; print(' GET /api/v1/pets/$petId(详情)→ ${r.status}'); check( r.status == 200 && detailUrl != null, '**详情**处 avatarUrl 非空(现签)', '详情 avatarUrl 为 null: ${r.body}', ); var (detailStatus, detailBytes) = await presignedGet(detailUrl!); print( ' GET <详情 avatarUrl> ${redactSignedUrl(detailUrl)} → ' '$detailStatus(${detailBytes.length} 字节)', ); check( detailStatus == 200 && const ListEquality().equals(detailBytes, _imageBytes), '详情 avatarUrl 可下载且与上传逐字节一致', '详情 avatarUrl 下载不符: HTTP $detailStatus / ${detailBytes.length} 字节', ); r = await call('GET', '$petUrl/api/v1/pets', token: tokenA); print(' GET /api/v1/pets(列表)→ ${r.status}'); final listed = r.list.firstWhere((e) => (e as Map)['id'] == petId) as Map; final listUrl = listed['avatarUrl'] as String?; check( r.status == 200 && listUrl != null, '**列表**处 avatarUrl 亦非空(四处 Pet 响应统一扩字段)', '列表 avatarUrl 为 null: ${jsonEncode(listed)}', ); check( !listed.containsKey('avatarAssetId'), '列表项同样不外露 avatarAssetId', '列表项外露 avatarAssetId: ${listed.keys}', ); final (listStatus, listBytes) = await presignedGet(listUrl!); print(' GET <列表 avatarUrl> → $listStatus(${listBytes.length} 字节)'); check( listStatus == 200 && const ListEquality().equals(listBytes, _imageBytes), '列表 avatarUrl 可下载且字节一致(两处 URL 各自现签、均有效)', '列表 avatarUrl 下载不符: HTTP $listStatus', ); r = await patchPet(tokenA, petId, { 'version': petVersion, 'avatarAssetId': null, }); print( ' PATCH /api/v1/pets/$petId {avatarAssetId: null}(三态清除)→ ${r.status}', ); check( r.status == 200 && r.data['avatarUrl'] == null, '**显式 null 清除宠物头像生效**(avatarAssetId 是 UpdatePetRequest 唯一的三态字段)', '宠物头像未被清除: ${r.body}', ); petVersion = r.data['version'] as int; r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); check( r.data['avatarUrl'] == null, '回读确认宠物头像已清除', '清除后回读 avatarUrl 非 null: ${r.data['avatarUrl']}', ); _passed++; print(''); // ================================================================ // [9/10] 宠物头像 asset 校验 + version 仍必填 // ================================================================ print('[9/10] 宠物头像 asset 校验:错用途/幽灵 → 404/40405;uploading → 422/42203'); final petNotFoundCases = { '错用途:user_avatar(跨域头像不通用)': assetUserAvatar, '错用途:post_image': assetPostImage, '幽灵 assetId': uuidV4(), '他人(B)的 asset': assetOfB, }; for (final entry in petNotFoundCases.entries) { r = await patchPet(tokenA, petId, { 'version': petVersion, 'avatarAssetId': entry.value, }); print(' PATCH /pets/{id} ← ${entry.key} → ${r.status} / code ${r.code}'); check( r.status == 404 && r.code == 40405, '${entry.key} → 404/40405(${r.message})', '${entry.key} 未按 40405 拒绝: ${r.status} ${r.body}', ); } final petAssetUploading = await uploadAsset( tokenA, 'pet_avatar', complete: false, label: ', 真未直传', ); r = await patchPet(tokenA, petId, { 'version': petVersion, 'avatarAssetId': petAssetUploading, }); print( ' PATCH /pets/{id} ← 本人 pet_avatar 但仍 uploading → ${r.status} / code ${r.code}', ); check( r.status == 422 && r.code == 42203, '本人、用途相符但未就绪 → 422/42203(${r.message})', '未就绪 pet asset 未按 42203 拒绝: ${r.status} ${r.body}', ); r = await patchPet(tokenA, petId, {'avatarAssetId': assetPetAvatar}); print( ' PATCH /pets/{id}(缺 version,只带 avatarAssetId)→ ${r.status} / code ${r.code}', ); check( r.status == 400 && r.code == 40000, 'version 仍必填:缺失 400/40000(${r.message})——即便只改头像', '缺 version 未被拒: ${r.status} ${r.body}', ); r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); check( r.status == 200 && r.data['avatarUrl'] == null && r.data['version'] == petVersion, '以上 6 次失败的 PATCH 均无副作用(头像仍为 null,version 未 +1)', '失败的 PATCH 产生了副作用: ${r.body}', ); _passed++; print(''); // ================================================================ // [10/10] GET /me/community-stats:空数据 0/0 → 自赞 → 软删归零 // ================================================================ print('[10/10] GET /api/v1/me/community-stats:空数据 0/0 → 发帖自赞 1/1 → 软删归零'); r = await call('GET', '$communityUrl/api/v1/me/community-stats'); print(' GET /me/community-stats(无 token)→ ${r.status} / code ${r.code}'); check( r.status == 401 && r.code == 40101, '无 token 401/40101(唯一的错误谱)', '无 token 未被拒: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/community-stats', token: tokenA, ); print( ' GET /me/community-stats(A 尚未发帖)→ ${r.status} ${jsonEncode(r.data)}', ); check( r.status == 200 && r.code == 0, '空数据返回 200(**永不 404**:任何已认证用户都有 stats)', '空数据未返回 200: ${r.status} ${r.body}', ); check( r.data['receivedLikeCount'] == 0 && r.data['publishedPostCount'] == 0, '空数据两字段均为 **0 而非 null**(COALESCE(SUM(like_count), 0))', '空数据不为 0/0: ${r.body}', ); check( r.data.keys.toSet().length == 2 && r.data.containsKey('receivedLikeCount') && r.data.containsKey('publishedPostCount'), 'CommunityStats 恰两字段(无多余键)', 'CommunityStats 形态不符: ${r.data.keys}', ); r = await createPost(tokenA, { 'title': 'M3.5 获赞聚合取证帖', 'content': 'M3.5 烟囱:本帖将被作者自赞,用于 community-stats 对账(ts=$ts)', 'category': 'general', 'status': 'published', }); print( ' POST /api/v1/posts (status=published, Idempotency-Key=) → ${r.status}', ); check(r.status == 201 && r.code == 0, '发布帖创建成功(201)', '发帖失败: ${r.body}'); final statsPostId = r.data['id'] as String; print(' postId: $statsPostId'); r = await call( 'GET', '$communityUrl/api/v1/me/community-stats', token: tokenA, ); print(' GET /me/community-stats(发布 1 帖,未获赞)→ ${jsonEncode(r.data)}'); check( r.data['publishedPostCount'] == 1 && r.data['receivedLikeCount'] == 0, '作品数 1、获赞 0(发布即计入作品集合)', '发布后计数不符: ${r.body}', ); r = await call( 'PUT', '$communityUrl/api/v1/posts/$statsPostId/like', token: tokenA, ); print( ' PUT /posts/{id}/like(**A 自赞**)→ ${r.status} ${jsonEncode(r.data)}', ); check( r.status == 200 && r.data['liked'] == true && r.data['likeCount'] == 1, '自赞成功,帖级 likeCount=1', '自赞不符: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/community-stats', token: tokenA, ); print(' GET /me/community-stats(自赞后)→ ${jsonEncode(r.data)}'); check( r.data['receivedLikeCount'] == 1 && r.data['publishedPostCount'] == 1, '**receivedLikeCount=1 / publishedPostCount=1**:自己赞自己**计入**' '(与帖子详情 likeCount 同一口径,两处数字对得上)', '自赞后计数不符: ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/posts/$statsPostId', token: tokenA, ); check( r.data['likeCount'] == 1, '帖子详情 likeCount=1 与 receivedLikeCount 对账一致', '两处口径不一致: 详情 ${r.data['likeCount']}', ); r = await createPost(tokenA, { 'title': 'M3.5 草稿(不应计入作品数)', 'content': '草稿尚非作品,其获赞也不可能存在(ts=$ts)', 'status': 'draft', }); check( r.status == 201 && r.data['status'] == 'draft', '草稿创建成功(201)', '草稿创建失败: ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/community-stats', token: tokenA, ); print(' GET /me/community-stats(新增 1 草稿后)→ ${jsonEncode(r.data)}'); check( r.data['publishedPostCount'] == 1 && r.data['receivedLikeCount'] == 1, '**草稿不计入**作品数(作品数仍为 1)', '草稿被计入: ${r.body}', ); r = await createPost(tokenA, { 'title': 'M3.5 第二篇发布帖', 'content': '第二篇已发布帖,作品数应累加到 2(ts=$ts)', 'status': 'published', }); check(r.status == 201, '第二篇发布帖创建成功(201)', '第二帖失败: ${r.body}'); r = await call( 'GET', '$communityUrl/api/v1/me/community-stats', token: tokenA, ); print(' GET /me/community-stats(发布 2 帖)→ ${jsonEncode(r.data)}'); check( r.data['publishedPostCount'] == 2 && r.data['receivedLikeCount'] == 1, '作品数累加为 2,获赞仍为 1(多帖求和口径正确)', '两帖计数不符: ${r.body}', ); // 他人帖不串号:B 发布并自赞一帖,A 的 stats 不受影响。 r = await createPost(tokenB, { 'title': 'B 的帖(不应进 A 的 stats)', 'content': 'B 的已发布帖 + 自赞,用于验证主体隔离(ts=$ts)', 'status': 'published', }); check(r.status == 201, 'B 发布帖创建成功(201)', 'B 发帖失败: ${r.body}'); final postOfB = r.data['id'] as String; r = await call( 'PUT', '$communityUrl/api/v1/posts/$postOfB/like', token: tokenB, ); check(r.status == 200, 'B 自赞成功', 'B 自赞失败: ${r.body}'); r = await call( 'GET', '$communityUrl/api/v1/me/community-stats', token: tokenA, ); print(' GET /me/community-stats(B 也发帖自赞后,A 视角)→ ${jsonEncode(r.data)}'); check( r.data['publishedPostCount'] == 2 && r.data['receivedLikeCount'] == 1, '他人帖**不串号**进 A 的 stats(主体恒为 token 里的调用者)', 'A 的 stats 被 B 的帖污染: ${r.body}', ); r = await call( 'DELETE', '$communityUrl/api/v1/posts/$statsPostId', token: tokenA, ); print(' DELETE /api/v1/posts/$statsPostId(软删被赞的那帖)→ ${r.status}'); check( r.status == 200 && r.code == 0, '软删成功', '软删失败: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/community-stats', token: tokenA, ); print(' GET /me/community-stats(软删后)→ ${jsonEncode(r.data)}'); check( r.data['receivedLikeCount'] == 0 && r.data['publishedPostCount'] == 1, '**软删帖后获赞归零**(1→0)、作品数回落 1(删帖即撤回其数字,' '与 /me/posts、Feed 可见性一致)', '软删后计数不符: ${r.body}', ); _passed++; print(''); print('=== M3.5 E2E 烟囱测试全部通过 ✓($_passed/10 场景)==='); print('E2E_USERNAME_A=$usernameA'); print('E2E_USER_ID_A=$userIdA'); print('E2E_USER_ID_B=$userIdB'); print('E2E_USER_AVATAR_ASSET_ID=$assetUserAvatar'); print('E2E_PET_AVATAR_ASSET_ID=$assetPetAvatar'); print('E2E_PET_ID=$petId'); print('E2E_STATS_POST_ID=$statsPostId(已软删)'); } 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; } }