Files
patbond-flutter/test/features/profile/profile_models_test.dart
T
lixi a4a97c03c2 新增:资料页真实化 + 编辑页——/me 读写、PATCH 三态、获赞与作品统计(T3.5-08)
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码
demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。

- `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落
  显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」
  也编码成 null,用户只改昵称就会连头像一起被服务端清掉。
- `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username`
  **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值
  预填,避免把展示约定固化成真实昵称。
- `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级
  这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。
- 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口,
  未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。
- 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐;
  纯空白是校验失败而非隐式清空。
- `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet`
  (单图、预览确认后才交付 ready assetId,孤儿防护不变)。
- 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository`
  此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被
  触发;本单是第一个真实消费者,桌面实测即暴露。

测试 526 → 588(+62)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:00 +08:00

154 lines
5.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/profile/profile_display.dart';
import '../../helpers/auth_test_helpers.dart';
void main() {
group('展示名回退 nickname ?? username', () {
test('有昵称:展示昵称,nickname 字段保留 DB 原值', () {
final profile = buildProfile(username: 'llx', nickname: '小柴');
expect(profile.displayName, '小柴');
expect(profile.nickname, '小柴');
});
test('无昵称:展示 username,但 nickname 仍为 null(不被回退值污染)', () {
final profile = buildProfile(username: 'llx');
expect(profile.displayName, 'llx');
expect(
profile.nickname,
isNull,
reason: '回退只在展示层发生;nickname 必须保持 null,否则编辑页预填后会被固化成真昵称',
);
});
test('hasAvatar 只看 avatarUrl(响应不外露 avatarAssetId', () {
expect(buildProfile().hasAvatar, isFalse);
expect(
buildProfile(avatarUrl: 'https://m/a.jpg?X-Amz-Signature=s').hasAvatar,
isTrue,
);
});
});
group('UpdateMeRequest 三态载荷', () {
test('什么都没碰 → isEmpty 且 JSON 为空对象', () {
const request = UpdateMeRequest();
expect(request.isEmpty, isTrue);
expect(request.toJson(), isEmpty);
});
test('只改昵称:avatarAssetId 键**不出现**(不改,而不是清空)', () {
const request = UpdateMeRequest(nickname: PatchField<String>.value('小柴'));
final json = request.toJson();
expect(json, {'nickname': '小柴'});
expect(
json.containsKey('avatarAssetId'),
isFalse,
reason: '发成 avatarAssetId: null 会把用户头像一起清掉',
);
});
test('只清昵称:nickname 显式 null,头像键仍不出现', () {
const request = UpdateMeRequest(nickname: PatchField<String>.clear());
final json = request.toJson();
expect(json.containsKey('nickname'), isTrue);
expect(json['nickname'], isNull);
expect(json.containsKey('avatarAssetId'), isFalse);
});
test('一次改两样:两键齐出', () {
const request = UpdateMeRequest(
nickname: PatchField<String>.value('小柴'),
avatarAssetId: PatchField<String>.value('asset-9'),
);
expect(request.toJson(), {'nickname': '小柴', 'avatarAssetId': 'asset-9'});
expect(request.isEmpty, isFalse);
});
test('只清头像:nickname 键不出现', () {
const request = UpdateMeRequest(
avatarAssetId: PatchField<String>.clear(),
);
final json = request.toJson();
expect(json.containsKey('nickname'), isFalse);
expect(json['avatarAssetId'], isNull);
});
});
group('昵称校验与 ck_users_nickname 对齐', () {
test('1 码点通过;32 CJK 通过;33 拒', () {
expect(validateNickname('柴'), isNull);
expect(validateNickname('柴' * 32), isNull);
expect(validateNickname('柴' * 33), isNotNull);
});
test('32 个 emojiUTF-16 长度 64)通过——长度按码点计而非 String.length', () {
const emoji = '🐕';
final name = emoji * 32;
expect(name.length, 64, reason: '前提:UTF-16 长度确为 64');
expect(
validateNickname(name),
isNull,
reason: '按 String.length 校验会误拒数据库能存的 32 emoji 昵称',
);
expect(validateNickname(emoji * 33), isNotNull);
});
test('btrim 对齐:前后空格不计入长度', () {
expect(validateNickname(' 豆豆 '), isNull);
expect(validateNickname(' ${'柴' * 32} '), isNull);
});
test('纯空白不是隐式清空,而是校验失败并指向显式清除入口', () {
final message = validateNickname(' ');
expect(message, isNotNull);
expect(message, contains('清除昵称'));
});
});
group('错误文案分层', () {
test('保存失败按 PATCH /me 错误谱分层', () {
expect(
profileSaveErrorMessage(
const ApiBusinessException(code: ApiCodes.paramError, message: 'x'),
),
'填写内容不符合要求,请检查后重试',
);
expect(
profileSaveErrorMessage(
const ApiBusinessException(
code: ApiCodes.mediaNotFound,
message: 'x',
),
),
'头像已失效,请重新上传',
);
expect(
profileSaveErrorMessage(
const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'x',
),
),
'头像还没上传完,请稍后重试',
);
expect(
profileSaveErrorMessage(const ApiNetworkException()),
'网络异常,请检查网络后重试',
);
});
test('加载失败文案不透出服务端 message', () {
expect(
profileLoadErrorMessage(
const ApiNetworkException('Connection refused'),
),
'网络异常,请检查网络后重试',
);
expect(profileLoadErrorMessage(null), '加载失败,请稍后重试');
});
});
}