fbcd734688
v0.4.0 发布 checklist 第 2 步:既有三份回归脚本(M1 7/7、M2 11/11、M3 14/14) 对 compose 六容器全绿后,补上 M3.5 新增对外面的验证,与前三份并列于仓库根目录。 覆盖 10 场景 / 95 断言(冻结契约 openapi v1.4.0,32 路径 45 操作 75 schema): - GET /me 初始形态:nickname/avatarUrl 键恒在且双 null,不外露 avatarAssetId - PATCH /me 三态:键缺省不改 / 显式 null 清空 / 给值设置;空 patch、纯空白、 空串、只带未声明字段、非法 UUID、坏 JSON 六格 400/40000;同 body 重放幂等 - 昵称边界按码点计:1 与 32 CJK 可、32 emoji 可(UTF-16 长度 64 却过关)、 33 两种字符集均拒、btrim 对齐 - 用户头像端到端:两步上传 user_avatar → 挂载 → 预签名 GET 逐字节一致 → 去签名直访 403(桶私有) - 宠物头像:PATCH /pets 挂 pet_avatar → 详情与列表两处 avatarUrl 均可下载 → 显式 null 清除;version 仍必填 - 两域 asset 校验矩阵:幽灵/他人/错用途(含 user_avatar↔pet_avatar 跨域) → 404/40405 且防枚举响应体一致;本人用途相符但 uploading → 422/42203; 失败路径零副作用 - GET /me/community-stats:无 token 401、空数据 0/0 永不 404、自赞计入、 草稿不计、他人帖不串号、软删后获赞归零、与帖详情 likeCount 对账 - media purpose 白名单三值放行,白名单外 id_card → 400/40000 结果:10/10 全绿,契约偏差 0,无需放宽任何断言(错 purpose 实测 404/40405, 与契约一致)。三份既有脚本逐字未改。 脱敏:token 截断、预签名 URL 签名 query 整体抹为 <SIGNATURE_REDACTED>、 Idempotency-Key 占位。 门禁:flutter analyze 0 issue;dart format --set-exit-if-changed lib test 0 changed (四份根目录脚本另跑显式检查亦 0 changed);flutter test 597 passed 与基线一致。 patbond-api 与 patbond-doc 代码/契约零改动。 报告:patbond-doc iteration-3.5/08-release-e2e-regression.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1167 lines
45 KiB
Dart
1167 lines
45 KiB
Dart
#!/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 整体抹为 `<SIGNATURE_REDACTED>`、
|
||
/// 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 = <String>{
|
||
'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))}...<REDACTED>';
|
||
|
||
/// 预签名 URL 脱敏:保留 host + 对象路径,签名 query 整体抹掉。
|
||
String redactSignedUrl(String url) {
|
||
final uri = Uri.parse(url);
|
||
return '${uri.scheme}://${uri.host}:${uri.port}${uri.path}'
|
||
'?<SIGNATURE_REDACTED>';
|
||
}
|
||
|
||
/// 昵称脱敏无必要,但超长昵称按码点数概述以保持输出可读。
|
||
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<String, dynamic> json;
|
||
Resp(this.status, this.body, this.json);
|
||
|
||
Map<String, dynamic> get data => json['data'] as Map<String, dynamic>;
|
||
List<dynamic> get list => json['data'] as List<dynamic>;
|
||
int? get code => json['code'] as int?;
|
||
String? get message => json['message'] as String?;
|
||
}
|
||
|
||
/// 通用调用。[rawBody] 用于绕过 jsonEncode 直发原文(坏 JSON 等场景)。
|
||
Future<Resp> call(
|
||
String method,
|
||
String url, {
|
||
String? token,
|
||
Object? body,
|
||
String? rawBody,
|
||
Map<String, String>? 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<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 原样携带。
|
||
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)}';
|
||
}
|
||
|
||
final _imageBytes = base64Decode(testJpegBase64);
|
||
|
||
/// 两步上传:createUpload → (可选)预签名 PUT 直传 → (可选)complete 置 ready。
|
||
/// [complete] 为 false 时 asset 停在 `uploading`(供 422/42203 取证)。
|
||
Future<String> 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<Resp> createPost(String token, Map<String, dynamic> body) => call(
|
||
'POST',
|
||
'$communityUrl/api/v1/posts',
|
||
token: token,
|
||
body: body,
|
||
headers: {'Idempotency-Key': uuidV4()},
|
||
);
|
||
|
||
/// 断言 Me 形态:键集合恰为冻结的 6 键,且**不外露** avatarAssetId。
|
||
void assertMeShape(Map<String, dynamic> 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<Resp> patchMe(String token, Object body) =>
|
||
call('PATCH', '$userUrl/api/v1/me', token: token, body: body);
|
||
|
||
Future<Resp> getMe(String token) =>
|
||
call('GET', '$userUrl/api/v1/me', token: token);
|
||
|
||
Future<Resp> patchPet(String token, String petId, Map<String, dynamic> 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(
|
||
<String>{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 <presigned avatarUrl> → $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, <String, dynamic>{});
|
||
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 = <String, String>{
|
||
'幽灵 assetId': ghostAssetId,
|
||
'他人(B)的 user_avatar': assetOfB,
|
||
'错用途:post_image': assetPostImage,
|
||
'错用途:pet_avatar(跨域头像不通用)': assetPetAvatar,
|
||
};
|
||
final notFoundBodies = <String>[];
|
||
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<String, dynamic>;
|
||
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 = <String, String>{
|
||
'错用途: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=<KEY-1>) → ${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<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;
|
||
}
|
||
}
|