Files
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

201 lines
6.9 KiB
Dart
Raw Permalink 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 'dart:async';
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/community/community_models.dart';
import 'package:patbond_flutter/features/profile/profile_controller.dart';
import '../../helpers/auth_test_helpers.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
late FakeCommunityRepository community;
setUp(() {
community = FakeCommunityRepository();
});
ProfileController build(FakeAuthRepository auth) =>
ProfileController(authRepository: auth, communityRepository: community);
test('四态:initial → loading → ready(资料 + 两块统计齐备)', () async {
final gate = Completer<UserProfile>();
final controller = build(FakeAuthRepository(meHandler: () => gate.future));
community.onGetMyCommunityStats = () async =>
CommunityStats.fromJson(sampleCommunityStatsJson());
community.onGetFollowStats = (_) async =>
FollowStats.fromJson(sampleFollowStatsJson());
expect(controller.phase, ProfileLoadPhase.initial);
final pending = controller.refresh();
expect(controller.phase, ProfileLoadPhase.loading);
gate.complete(buildProfile(nickname: '小柴'));
await pending;
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.statsPhase, ProfileStatsPhase.ready);
expect(controller.displayName, '小柴');
expect(controller.communityStats!.receivedLikeCount, 128);
expect(controller.followStats!.followerCount, 24);
});
test('/me 失败且无副本 → error 态 + 可重试;不外抛', () async {
var attempts = 0;
final auth = FakeAuthRepository(
meHandler: () async {
attempts += 1;
if (attempts == 1) throw const ApiNetworkException('断网');
return buildProfile(nickname: '小柴');
},
);
final controller = build(auth);
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.error);
expect(controller.lastError, isA<ApiNetworkException>());
expect(controller.profile, isNull);
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.displayName, '小柴');
});
test('已有副本时刷新失败保留副本(不打断阅读,停在 ready)', () async {
var attempts = 0;
final controller = build(
FakeAuthRepository(
meHandler: () async {
attempts += 1;
if (attempts == 1) return buildProfile(nickname: '小柴');
throw const ApiNetworkException('断网');
},
),
);
await controller.refresh();
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.displayName, '小柴');
expect(controller.lastError, isA<ApiNetworkException>());
});
test('统计失败只降级统计块,资料仍 ready;refreshStats 可单独重试', () async {
var statsAttempts = 0;
community.onGetMyCommunityStats = () async {
statsAttempts += 1;
if (statsAttempts == 1) throw const ApiNetworkException('断网');
return CommunityStats.fromJson(sampleCommunityStatsJson());
};
community.onGetFollowStats = (_) async =>
FollowStats.fromJson(sampleFollowStatsJson());
final controller = build(FakeAuthRepository());
await controller.refresh();
expect(controller.phase, ProfileLoadPhase.ready);
expect(controller.statsPhase, ProfileStatsPhase.error);
expect(controller.communityStats, isNull);
await controller.refreshStats();
expect(controller.statsPhase, ProfileStatsPhase.ready);
expect(controller.phase, ProfileLoadPhase.ready);
});
test('空数据:两个数字为 0(不是 null、不是 404', () async {
community.onGetMyCommunityStats = () async => CommunityStats.fromJson(
sampleCommunityStatsJson(receivedLikeCount: 0, publishedPostCount: 0),
);
community.onGetFollowStats = (_) async => FollowStats.fromJson(
sampleFollowStatsJson(followerCount: 0, followingCount: 0),
);
final controller = build(FakeAuthRepository());
await controller.refresh();
expect(controller.communityStats!.receivedLikeCount, 0);
expect(controller.communityStats!.publishedPostCount, 0);
});
test('save:空 patch 直接短路,不发请求(服务端对空 patch 答 400', () async {
final auth = FakeAuthRepository(
updateMeHandler: (_) async => buildProfile(nickname: '不该被调用'),
);
final controller = build(auth);
await controller.refresh();
await controller.save(const UpdateMeRequest());
expect(auth.updateMePayloads, isEmpty);
});
test('save 成功即以服务端回显替换副本(含 avatarUrl 现签值)', () async {
final auth = FakeAuthRepository(
updateMeHandler: (_) async => buildProfile(
nickname: '小柴',
avatarUrl: 'https://m/a.jpg?X-Amz-Signature=new',
),
);
final controller = build(auth);
await controller.refresh();
expect(controller.displayName, 'llx');
await controller.save(
const UpdateMeRequest(nickname: PatchField<String>.value('小柴')),
);
expect(controller.displayName, '小柴');
expect(controller.profile!.avatarUrl, contains('X-Amz-Signature=new'));
expect(auth.updateMePayloads.single, {'nickname': '小柴'});
});
test('save 失败按类型化异常外抛(供编辑页分层呈现),副本不动', () async {
final auth = FakeAuthRepository(
updateMeHandler: (_) async => throw const ApiBusinessException(
code: ApiCodes.mediaNotReady,
message: 'not ready',
),
);
final controller = build(auth);
await controller.refresh();
await expectLater(
controller.save(
const UpdateMeRequest(avatarAssetId: PatchField<String>.value('a-1')),
),
throwsA(
isA<ApiBusinessException>().having(
(e) => e.code,
'code',
ApiCodes.mediaNotReady,
),
),
);
expect(controller.displayName, 'llx');
});
test('reset:登出清空资料与统计,回 initial(跨账号不泄漏)', () async {
final controller = build(FakeAuthRepository());
await controller.refresh();
expect(controller.profile, isNotNull);
controller.reset();
expect(controller.phase, ProfileLoadPhase.initial);
expect(controller.profile, isNull);
expect(controller.displayName, isNull);
expect(controller.communityStats, isNull);
expect(controller.followStats, isNull);
});
test('follow-stats 用的是本人 userId(路径主体正确)', () async {
final controller = build(
FakeAuthRepository(meHandler: () async => buildProfile(userId: 'u-42')),
);
await controller.refresh();
expect(community.calls, contains('followStats:u-42'));
expect(community.calls, contains('communityStats'));
});
}