Files
lixi 19bd8c1810
CI / flutter-gates (push) Successful in 2m26s
新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
- 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>
2026-09-09 12:01:25 +08:00

286 lines
9.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 '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);
});
});
}