新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s

- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射
  (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等),
  未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用
- community_repository:13 路径 19 操作全覆盖;createPost/createComment
  必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/
  关注走 PUT/DELETE 语义幂等
- community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射
- toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫,
  点赞/收藏共用一套参数化状态机,权威终态对账收敛
- community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 +
  游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态
- app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher,
  登出同步 reset
- 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 12:01:25 +08:00
parent 66f983d680
commit 19bd8c1810
14 changed files with 2867 additions and 28 deletions
@@ -0,0 +1,285 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import '../../helpers/community_test_helpers.dart';
void main() {
group('响应 DTO 逐字段映射(契约 v1.3.0)', () {
test('Post:完整形态含作者 / media / 互动计数 / publishedAt', () {
final post = Post.fromJson(samplePostJson());
expect(post.id, 'p-1');
expect(post.author.userId, 'u-1');
expect(post.author.nickname, '毛毛的铲屎官');
expect(post.petId, 'pet-1');
expect(post.category, PostCategory.general);
expect(post.title, '今天的豆豆');
expect(post.content, '晒了一下午太阳。');
expect(post.status, PostStatus.published);
expect(post.visibility, PostVisibility.public);
expect(post.media.single.assetId, 'a-1');
expect(post.media.single.isCover, isTrue);
expect(post.likeCount, 6);
expect(post.commentCount, 3);
expect(post.bookmarkCount, 2);
expect(post.likedByMe, isFalse);
expect(post.bookmarkedByMe, isFalse);
expect(post.publishedAt, isNotNull);
expect(post.version, 1);
});
test('Postdraft 态 publishedAt 为 null(仅 published 非空)', () {
final post = Post.fromJson(samplePostJson(status: 'draft'));
expect(post.status, PostStatus.draft);
expect(post.publishedAt, isNull);
});
test('FeedCard:卡片裁剪形态,纯文字帖 coverImage 为 null', () {
final withCover = FeedCard.fromJson(sampleFeedCardJson());
expect(withCover.coverImage!.url, contains('X-Amz-Signature'));
expect(withCover.mediaCount, 1);
expect(withCover.contentPreview, '晒了一下午太阳。');
expect(withCover.publishedAt, DateTime.parse('2026-09-08T10:05:00.000Z'));
final textOnly = FeedCard.fromJson(
sampleFeedCardJson()
..['coverImage'] = null
..['mediaCount'] = 0,
);
expect(textOnly.coverImage, isNull);
expect(textOnly.mediaCount, 0);
});
test('categoryai_creation(读侧预留)按线上 snake_case 解析', () {
final card = FeedCard.fromJson(
sampleFeedCardJson()..['category'] = 'ai_creation',
);
expect(card.category, PostCategory.aiCreation);
expect(PostCategory.aiCreation.wire, 'ai_creation');
});
test('AuthorSummarynickname 与 avatarUrl 同为 null 即降级/墓碑形态', () {
final normal = AuthorSummary.fromJson(sampleAuthorJson());
expect(normal.isDegraded, isFalse);
final degraded = AuthorSummary.fromJson(
sampleAuthorJson(nickname: null, avatarUrl: null),
);
expect(degraded.userId, 'u-1');
expect(degraded.isDegraded, isTrue);
// 仅缺头像不算降级(无头像 / 头像非 ready 也是 null)。
final noAvatar = AuthorSummary.fromJson(
sampleAuthorJson(avatarUrl: null),
);
expect(noAvatar.isDegraded, isFalse);
});
test('PostCommentreplyToUser 非回复为 null@ 回复含降级形态', () {
final plain = PostComment.fromJson(sampleCommentJson());
expect(plain.replyToUser, isNull);
expect(plain.postId, 'p-1');
final reply = PostComment.fromJson(
sampleCommentJson(
replyToUser: sampleAuthorJson(
userId: 'u-2',
nickname: null,
avatarUrl: null,
),
),
);
expect(reply.replyToUser!.userId, 'u-2');
expect(reply.replyToUser!.isDegraded, isTrue);
});
test('MediaUploadCredentialsrequiredHeaders 原样映射为字符串表', () {
final credentials = MediaUploadCredentials.fromJson(
sampleUploadCredentialsJson(),
);
expect(credentials.assetId, 'a-1');
expect(credentials.method, 'PUT');
expect(credentials.requiredHeaders, {'Content-Type': 'image/jpeg'});
expect(credentials.expiresAt, DateTime.parse('2026-09-08T10:10:00.000Z'));
});
test('MediaAssetready 态 url/readyAt 非空,uploading 态为 null', () {
final ready = MediaAsset.fromJson(sampleMediaAssetJson());
expect(ready.status, MediaAssetStatus.ready);
expect(ready.url, isNotNull);
expect(ready.readyAt, isNotNull);
expect(ready.byteSize, 204800);
final uploading = MediaAsset.fromJson(
sampleMediaAssetJson(status: 'uploading'),
);
expect(uploading.status, MediaAssetStatus.uploading);
expect(uploading.url, isNull);
expect(uploading.readyAt, isNull);
});
test('LikeState / BookmarkState / FollowState / FollowStats 终态解析', () {
final like = LikeState.fromJson({'liked': true, 'likeCount': 7});
expect(like.liked, isTrue);
expect(like.likeCount, 7);
final bookmark = BookmarkState.fromJson({
'bookmarked': false,
'bookmarkCount': 0,
});
expect(bookmark.bookmarked, isFalse);
expect(bookmark.bookmarkCount, 0);
final follow = FollowState.fromJson({
'following': true,
'followerCount': 12,
});
expect(follow.following, isTrue);
expect(follow.followerCount, 12);
final stats = FollowStats.fromJson({
'followerCount': 12,
'followingCount': 34,
'followedByMe': false,
});
expect(stats.followingCount, 34);
expect(stats.followedByMe, isFalse);
});
test('未知枚举取值抛 FormatException(契约漂移测试期暴露)', () {
expect(
() => Post.fromJson(samplePostJson()..['status'] = 'hidden'),
throwsFormatException,
);
expect(
() => FeedCard.fromJson(sampleFeedCardJson()..['category'] = 'topic'),
throwsFormatException,
);
expect(
() => MediaAsset.fromJson(sampleMediaAssetJson(status: 'deleted')),
throwsFormatException,
);
expect(
() => Post.fromJson(samplePostJson()..['visibility'] = 'private'),
throwsFormatException,
);
});
});
group('请求 DTO 序列化', () {
test('CreatePostRequest:可选字段缺席不出现,media 按项序列化', () {
const minimal = CreatePostRequest(content: '纯文字帖');
expect(minimal.toJson(), {'content': '纯文字帖'});
const full = CreatePostRequest(
content: '正文',
title: '标题',
category: PostCategory.help,
status: PostStatus.published,
petId: 'pet-1',
media: [
PostMediaAttachRequest(assetId: 'a-1', position: 0, isCover: true),
PostMediaAttachRequest(assetId: 'a-2', position: 1, caption: '第二张'),
],
);
expect(full.toJson(), {
'content': '正文',
'title': '标题',
'category': 'help',
'status': 'published',
'petId': 'pet-1',
'media': [
{'assetId': 'a-1', 'position': 0, 'isCover': true},
{'assetId': 'a-2', 'position': 1, 'caption': '第二张'},
],
});
});
test('UpdatePostRequestmedia 三态——缺席不动 / [] 清空 / 非空整组替换', () {
const absent = UpdatePostRequest(version: 2, title: '改标题');
expect(absent.toJson(), {'version': 2, 'title': '改标题'});
expect(absent.toJson().containsKey('media'), isFalse);
const clear = UpdatePostRequest(version: 2, media: []);
expect(clear.toJson(), {'version': 2, 'media': <Object?>[]});
const replace = UpdatePostRequest(
version: 2,
media: [PostMediaAttachRequest(assetId: 'a-3')],
);
expect(replace.toJson()['media'], [
{'assetId': 'a-3'},
]);
});
test('UpdatePostRequestpublish 即 status: published(唯一开放迁移)', () {
const publish = UpdatePostRequest(version: 1, publish: true);
expect(publish.toJson(), {'version': 1, 'status': 'published'});
const noPublish = UpdatePostRequest(version: 1, content: '改正文');
expect(noPublish.toJson().containsKey('status'), isFalse);
});
test('CreateMediaUploadRequestkind/purpose 按线上取值,sha256 可选', () {
const request = CreateMediaUploadRequest(
kind: MediaKind.image,
purpose: MediaPurpose.postImage,
mimeType: 'image/jpeg',
byteSize: 204800,
);
expect(request.toJson(), {
'kind': 'image',
'purpose': 'post_image',
'mimeType': 'image/jpeg',
'byteSize': 204800,
});
const withHash = CreateMediaUploadRequest(
kind: MediaKind.image,
purpose: MediaPurpose.postImage,
mimeType: 'image/webp',
byteSize: 1,
sha256:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
);
expect(withHash.toJson()['sha256'], hasLength(64));
});
test('CreateCommentRequestreplyToUserId 缺席不出现', () {
expect(const CreateCommentRequest(content: '好可爱!').toJson(), {
'content': '好可爱!',
});
expect(
const CreateCommentRequest(
content: '@回复',
replyToUserId: 'u-2',
).toJson(),
{'content': '@回复', 'replyToUserId': 'u-2'},
);
});
});
group('互动字段副本更新', () {
test('Post.copyWithInteraction 只动互动字段', () {
final post = Post.fromJson(samplePostJson());
final updated = post.copyWithInteraction(likedByMe: true, likeCount: 7);
expect(updated.likedByMe, isTrue);
expect(updated.likeCount, 7);
expect(updated.bookmarkCount, post.bookmarkCount);
expect(updated.content, post.content);
expect(updated.version, post.version);
});
test('FeedCard.copyWithInteraction 只动互动字段', () {
final card = sampleFeedCard();
final updated = card.copyWithInteraction(
bookmarkedByMe: true,
bookmarkCount: 3,
);
expect(updated.bookmarkedByMe, isTrue);
expect(updated.bookmarkCount, 3);
expect(updated.likeCount, card.likeCount);
expect(updated.contentPreview, card.contentPreview);
});
});
}