新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
CI / flutter-gates (push) Successful in 2m26s
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:
@@ -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('Post:draft 态 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('category:ai_creation(读侧预留)按线上 snake_case 解析', () {
|
||||
final card = FeedCard.fromJson(
|
||||
sampleFeedCardJson()..['category'] = 'ai_creation',
|
||||
);
|
||||
expect(card.category, PostCategory.aiCreation);
|
||||
expect(PostCategory.aiCreation.wire, 'ai_creation');
|
||||
});
|
||||
|
||||
test('AuthorSummary:nickname 与 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('PostComment:replyToUser 非回复为 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('MediaUploadCredentials:requiredHeaders 原样映射为字符串表', () {
|
||||
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('MediaAsset:ready 态 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('UpdatePostRequest:media 三态——缺席不动 / [] 清空 / 非空整组替换', () {
|
||||
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('UpdatePostRequest:publish 即 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('CreateMediaUploadRequest:kind/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('CreateCommentRequest:replyToUserId 缺席不出现', () {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user