diff --git a/test_e2e_m35_manual.dart b/test_e2e_m35_manual.dart new file mode 100644 index 0000000..703c733 --- /dev/null +++ b/test_e2e_m35_manual.dart @@ -0,0 +1,1166 @@ +#!/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; + } +}