新增: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,372 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/features/community/community_controller.dart';
|
||||
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||
|
||||
import '../../helpers/community_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
late FakeCommunityRepository repo;
|
||||
late CommunityController controller;
|
||||
|
||||
setUp(() {
|
||||
repo = FakeCommunityRepository();
|
||||
controller = CommunityController(repository: repo);
|
||||
});
|
||||
|
||||
tearDown(() => controller.dispose());
|
||||
|
||||
List<String> feedIds() => controller.feed.map((card) => card.id).toList();
|
||||
|
||||
group('Feed 四态与多页缓存', () {
|
||||
test('首屏成功:initial → loading → ready,页数据落位', () async {
|
||||
repo.onFeed = (limit, cursor) async =>
|
||||
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
|
||||
|
||||
expect(controller.phase, FeedPhase.initial);
|
||||
final pending = controller.refresh();
|
||||
expect(controller.phase, FeedPhase.loading);
|
||||
await pending;
|
||||
|
||||
expect(controller.phase, FeedPhase.ready);
|
||||
expect(feedIds(), ['p-1']);
|
||||
expect(controller.hasMore, isTrue);
|
||||
expect(controller.isEmpty, isFalse);
|
||||
});
|
||||
|
||||
test('首屏失败:error 态供 retry;重试成功恢复 ready', () async {
|
||||
var fail = true;
|
||||
repo.onFeed = (limit, cursor) async {
|
||||
if (fail) throw const ApiNetworkException('断网');
|
||||
return feedPage([sampleFeedCard()]);
|
||||
};
|
||||
|
||||
await controller.refresh();
|
||||
expect(controller.phase, FeedPhase.error);
|
||||
expect(controller.lastError, isA<ApiNetworkException>());
|
||||
expect(controller.feed, isEmpty);
|
||||
|
||||
fail = false;
|
||||
await controller.refresh();
|
||||
expect(controller.phase, FeedPhase.ready);
|
||||
expect(controller.lastError, isNull);
|
||||
expect(feedIds(), ['p-1']);
|
||||
});
|
||||
|
||||
test('刷新失败保留旧列表:不清空不闪空态,错误走 refreshError', () async {
|
||||
var fail = false;
|
||||
repo.onFeed = (limit, cursor) async {
|
||||
if (fail) throw const ApiNetworkException('超时');
|
||||
return feedPage([sampleFeedCard()]);
|
||||
};
|
||||
await controller.refresh();
|
||||
|
||||
fail = true;
|
||||
await controller.refresh();
|
||||
|
||||
expect(controller.phase, FeedPhase.ready);
|
||||
expect(feedIds(), ['p-1']);
|
||||
expect(controller.refreshError, isA<ApiNetworkException>());
|
||||
expect(controller.lastError, isNull);
|
||||
});
|
||||
|
||||
test('ready 且列表为空 → 空态', () async {
|
||||
repo.onFeed = (limit, cursor) async => feedPage(const []);
|
||||
await controller.refresh();
|
||||
expect(controller.isEmpty, isTrue);
|
||||
expect(controller.hasMore, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('加载更多与游标拼接', () {
|
||||
test('loadMore 携带上一页 nextCursor,追加不替换;到底后不再发', () async {
|
||||
repo.onFeed = (limit, cursor) async => switch (cursor) {
|
||||
null => feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true),
|
||||
'c1' => feedPage([sampleFeedCard(id: 'p-2')]),
|
||||
_ => fail('意外游标:$cursor'),
|
||||
};
|
||||
|
||||
await controller.refresh();
|
||||
await controller.loadMore();
|
||||
|
||||
expect(repo.calls, ['feed:cursor=null', 'feed:cursor=c1']);
|
||||
expect(feedIds(), ['p-1', 'p-2']);
|
||||
expect(controller.hasMore, isFalse);
|
||||
expect(controller.loadMorePhase, LoadMorePhase.idle);
|
||||
|
||||
// hasMore=false:不再发请求。
|
||||
await controller.loadMore();
|
||||
expect(repo.calls, hasLength(2));
|
||||
});
|
||||
|
||||
test('loadMore 失败:error 态保留列表,重试成功恢复', () async {
|
||||
var fail = true;
|
||||
repo.onFeed = (limit, cursor) async {
|
||||
if (cursor == null) {
|
||||
return feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
|
||||
}
|
||||
if (fail) throw const ApiNetworkException('超时');
|
||||
return feedPage([sampleFeedCard(id: 'p-2')]);
|
||||
};
|
||||
|
||||
await controller.refresh();
|
||||
await controller.loadMore();
|
||||
expect(controller.loadMorePhase, LoadMorePhase.error);
|
||||
expect(controller.loadMoreError, isA<ApiNetworkException>());
|
||||
expect(feedIds(), ['p-1']);
|
||||
|
||||
fail = false;
|
||||
await controller.loadMore();
|
||||
expect(controller.loadMorePhase, LoadMorePhase.idle);
|
||||
expect(feedIds(), ['p-1', 'p-2']);
|
||||
});
|
||||
|
||||
test('在途 loadMore 与刷新竞态:旧代次尾页丢弃,不重复不错位', () async {
|
||||
final tail = Completer<CursorPage<FeedCard>>();
|
||||
repo.onFeed = (limit, cursor) async {
|
||||
if (cursor == null) {
|
||||
return feedPage(
|
||||
[sampleFeedCard(id: 'p-fresh')],
|
||||
nextCursor: 'c1',
|
||||
hasMore: true,
|
||||
);
|
||||
}
|
||||
return tail.future;
|
||||
};
|
||||
|
||||
await controller.refresh();
|
||||
final pending = controller.loadMore(); // 在途旧代次尾页。
|
||||
await controller.refresh(); // 期间刷新:整体替换 + 代次 +1。
|
||||
tail.complete(feedPage([sampleFeedCard(id: 'p-stale')]));
|
||||
await pending;
|
||||
|
||||
expect(feedIds(), ['p-fresh']);
|
||||
expect(controller.hasMore, isTrue); // 保持新代次首页的分页状态。
|
||||
});
|
||||
|
||||
test('loading 中重复 loadMore 只发一请求(单飞)', () async {
|
||||
final tail = Completer<CursorPage<FeedCard>>();
|
||||
repo.onFeed = (limit, cursor) async => cursor == null
|
||||
? feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true)
|
||||
: tail.future;
|
||||
|
||||
await controller.refresh();
|
||||
final first = controller.loadMore();
|
||||
final second = controller.loadMore(); // loading 中:直接返回。
|
||||
tail.complete(feedPage([sampleFeedCard(id: 'p-2')]));
|
||||
await first;
|
||||
await second;
|
||||
|
||||
expect(repo.calls.where((c) => c == 'feed:cursor=c1'), hasLength(1));
|
||||
expect(feedIds(), ['p-1', 'p-2']);
|
||||
});
|
||||
});
|
||||
|
||||
group('ToggleSync 乐观更新(点赞 / 收藏同构)', () {
|
||||
Future<void> loadOneCard({bool liked = false, int likeCount = 6}) async {
|
||||
repo.onFeed = (limit, cursor) async =>
|
||||
feedPage([sampleFeedCard(likedByMe: liked, likeCount: likeCount)]);
|
||||
await controller.refresh();
|
||||
}
|
||||
|
||||
test('成功:同帧乐观翻转,权威计数覆盖乐观计数', () async {
|
||||
await loadOneCard();
|
||||
repo.onLikeToggle = (id, target) async =>
|
||||
const LikeState(liked: true, likeCount: 10); // 吸收他人并发 +3。
|
||||
|
||||
controller.toggleLike('p-1');
|
||||
// 同帧反馈:乐观 +1。
|
||||
expect(controller.feed.single.likedByMe, isTrue);
|
||||
expect(controller.feed.single.likeCount, 7);
|
||||
|
||||
await pumpEventQueue();
|
||||
// 权威终态覆盖。
|
||||
expect(controller.feed.single.likedByMe, isTrue);
|
||||
expect(controller.feed.single.likeCount, 10);
|
||||
expect(controller.toggleError, isNull);
|
||||
});
|
||||
|
||||
test('失败:恢复链起点快照,错误走 toggleError 轻提示', () async {
|
||||
await loadOneCard();
|
||||
repo.onLikeToggle = (id, target) async =>
|
||||
throw const ApiNetworkException('断网');
|
||||
|
||||
controller.toggleLike('p-1');
|
||||
expect(controller.feed.single.likedByMe, isTrue);
|
||||
|
||||
await pumpEventQueue();
|
||||
expect(controller.feed.single.likedByMe, isFalse);
|
||||
expect(controller.feed.single.likeCount, 6);
|
||||
expect(controller.toggleError, isA<ApiNetworkException>());
|
||||
});
|
||||
|
||||
test('在途连点单飞:只发一请求,完成后按最终意图补发一次', () async {
|
||||
await loadOneCard();
|
||||
final completers = <Completer<LikeState>>[];
|
||||
repo.onLikeToggle = (id, target) {
|
||||
final completer = Completer<LikeState>();
|
||||
completers.add(completer);
|
||||
return completer.future;
|
||||
};
|
||||
|
||||
controller.toggleLike('p-1'); // → true,在途。
|
||||
controller.toggleLike('p-1'); // → false,只记意图不发请求。
|
||||
expect(repo.calls.where((c) => c.startsWith('like')), hasLength(1));
|
||||
expect(controller.feed.single.likedByMe, isFalse);
|
||||
expect(controller.feed.single.likeCount, 6);
|
||||
|
||||
completers[0].complete(const LikeState(liked: true, likeCount: 7));
|
||||
await pumpEventQueue();
|
||||
// 确认态 true ≠ 最终意图 false → 补发 DELETE。
|
||||
expect(repo.calls, contains('unlike:p-1'));
|
||||
completers[1].complete(const LikeState(liked: false, likeCount: 6));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(controller.feed.single.likedByMe, isFalse);
|
||||
expect(controller.feed.single.likeCount, 6);
|
||||
// 全程恰两个请求(中间抖动被合并)。
|
||||
expect(
|
||||
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
|
||||
hasLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
test('补发目标与终态一致不再发:连点偶数次收敛回原意图', () async {
|
||||
await loadOneCard();
|
||||
final completers = <Completer<LikeState>>[];
|
||||
repo.onLikeToggle = (id, target) {
|
||||
final completer = Completer<LikeState>();
|
||||
completers.add(completer);
|
||||
return completer.future;
|
||||
};
|
||||
|
||||
controller.toggleLike('p-1'); // → true,在途。
|
||||
controller.toggleLike('p-1'); // → false。
|
||||
controller.toggleLike('p-1'); // → true(最终意图与在途目标一致)。
|
||||
|
||||
completers.single.complete(const LikeState(liked: true, likeCount: 20));
|
||||
await pumpEventQueue();
|
||||
|
||||
// 意图 == 确认态:不补发,权威计数覆盖。
|
||||
expect(
|
||||
repo.calls.where((c) => c.startsWith('like') || c.startsWith('unlike')),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(controller.feed.single.likedByMe, isTrue);
|
||||
expect(controller.feed.single.likeCount, 20);
|
||||
});
|
||||
|
||||
test('代次守卫:刷新后到达的旧成功响应丢弃,不覆盖新数据', () async {
|
||||
await loadOneCard();
|
||||
final inFlight = Completer<LikeState>();
|
||||
repo.onLikeToggle = (id, target) => inFlight.future;
|
||||
|
||||
controller.toggleLike('p-1');
|
||||
// 期间刷新:列表被服务端数据整体替换(liked=false count=0)。
|
||||
repo.onFeed = (limit, cursor) async =>
|
||||
feedPage([sampleFeedCard(likeCount: 0)]);
|
||||
await controller.refresh();
|
||||
|
||||
inFlight.complete(const LikeState(liked: true, likeCount: 99));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(controller.feed.single.likedByMe, isFalse);
|
||||
expect(controller.feed.single.likeCount, 0);
|
||||
});
|
||||
|
||||
test('代次守卫:刷新后到达的旧失败响应不回滚不提示', () async {
|
||||
await loadOneCard();
|
||||
final inFlight = Completer<LikeState>();
|
||||
repo.onLikeToggle = (id, target) => inFlight.future;
|
||||
|
||||
controller.toggleLike('p-1');
|
||||
repo.onFeed = (limit, cursor) async =>
|
||||
feedPage([sampleFeedCard(likeCount: 0)]);
|
||||
await controller.refresh();
|
||||
|
||||
inFlight.completeError(const ApiNetworkException('超时'));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(controller.feed.single.likedByMe, isFalse);
|
||||
expect(controller.feed.single.likeCount, 0);
|
||||
expect(controller.toggleError, isNull);
|
||||
});
|
||||
|
||||
test('收藏同构:乐观翻转 + 权威终态覆盖', () async {
|
||||
await loadOneCard();
|
||||
repo.onBookmarkToggle = (id, target) async =>
|
||||
const BookmarkState(bookmarked: true, bookmarkCount: 5);
|
||||
|
||||
controller.toggleBookmark('p-1');
|
||||
expect(controller.feed.single.bookmarkedByMe, isTrue);
|
||||
expect(controller.feed.single.bookmarkCount, 3);
|
||||
|
||||
await pumpEventQueue();
|
||||
expect(controller.feed.single.bookmarkCount, 5);
|
||||
});
|
||||
|
||||
test('对不在列表的 postId 点击作废:不发请求不崩溃', () async {
|
||||
await loadOneCard();
|
||||
controller.toggleLike('p-nonexistent');
|
||||
await pumpEventQueue();
|
||||
expect(repo.calls.where((c) => c.startsWith('like')), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('详情副本与登出清态', () {
|
||||
test('getPost:详情入缓存并回写 Feed 卡片互动字段', () async {
|
||||
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
|
||||
await controller.refresh();
|
||||
repo.onGetPost = (id) async =>
|
||||
Post.fromJson(samplePostJson(likedByMe: true, likeCount: 42));
|
||||
|
||||
final post = await controller.getPost('p-1');
|
||||
|
||||
expect(post.likeCount, 42);
|
||||
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
|
||||
expect(controller.feed.single.likedByMe, isTrue);
|
||||
expect(controller.feed.single.likeCount, 42);
|
||||
});
|
||||
|
||||
test('详情副本存在时 toggle 同步详情与卡片两份内存', () async {
|
||||
repo.onFeed = (limit, cursor) async => feedPage([sampleFeedCard()]);
|
||||
await controller.refresh();
|
||||
repo.onGetPost = (id) async => Post.fromJson(samplePostJson());
|
||||
await controller.getPost('p-1');
|
||||
repo.onLikeToggle = (id, target) async =>
|
||||
const LikeState(liked: true, likeCount: 7);
|
||||
|
||||
controller.toggleLike('p-1');
|
||||
expect(controller.cachedPost('p-1')!.likedByMe, isTrue);
|
||||
expect(controller.feed.single.likedByMe, isTrue);
|
||||
|
||||
await pumpEventQueue();
|
||||
expect(controller.cachedPost('p-1')!.likeCount, 7);
|
||||
expect(controller.feed.single.likeCount, 7);
|
||||
});
|
||||
|
||||
test('reset:清列表 / 游标 / 详情副本回 initial,在途响应作废', () async {
|
||||
repo.onFeed = (limit, cursor) async =>
|
||||
feedPage([sampleFeedCard()], nextCursor: 'c1', hasMore: true);
|
||||
await controller.refresh();
|
||||
final inFlight = Completer<LikeState>();
|
||||
repo.onLikeToggle = (id, target) => inFlight.future;
|
||||
controller.toggleLike('p-1');
|
||||
|
||||
controller.reset();
|
||||
|
||||
expect(controller.phase, FeedPhase.initial);
|
||||
expect(controller.feed, isEmpty);
|
||||
expect(controller.hasMore, isFalse);
|
||||
expect(controller.cachedPost('p-1'), isNull);
|
||||
expect(controller.toggleError, isNull);
|
||||
|
||||
// 登出后到达的在途响应作废,不写入任何状态。
|
||||
inFlight.complete(const LikeState(liked: true, likeCount: 7));
|
||||
await pumpEventQueue();
|
||||
expect(controller.feed, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||
import 'package:patbond_flutter/features/community/community_exceptions.dart';
|
||||
import 'package:patbond_flutter/features/community/community_models.dart';
|
||||
import 'package:patbond_flutter/features/community/community_repository.dart';
|
||||
|
||||
import '../../helpers/auth_test_helpers.dart';
|
||||
import '../../helpers/community_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
late SessionManager session;
|
||||
late FakeHttpAdapter adapter;
|
||||
late ApiCommunityRepository repository;
|
||||
|
||||
Future<void> setUpWith(
|
||||
Future<ResponseBody> Function(RequestOptions) handler,
|
||||
) async {
|
||||
session = SessionManager(store: InMemoryTokenStore());
|
||||
await session.updateTokens(sampleTokens(access: 'community-access'));
|
||||
final dio = buildPatbondDio(
|
||||
session: session,
|
||||
baseUrl: 'http://community.local',
|
||||
);
|
||||
adapter = FakeHttpAdapter(handler);
|
||||
dio.httpClientAdapter = adapter;
|
||||
final refresher = TokenRefresher(dio: dio, session: session);
|
||||
repository = ApiCommunityRepository(
|
||||
api: ApiClient(dio: dio, session: session, refresher: refresher),
|
||||
);
|
||||
}
|
||||
|
||||
group('请求线路(路径 / 方法 / 鉴权 / 参数)', () {
|
||||
test('createMediaUpload:POST /api/v1/media/uploads,携带 Bearer', () async {
|
||||
await setUpWith(
|
||||
(options) async =>
|
||||
jsonResponse(201, okEnvelope(sampleUploadCredentialsJson())),
|
||||
);
|
||||
|
||||
final credentials = await repository.createMediaUpload(
|
||||
const CreateMediaUploadRequest(
|
||||
kind: MediaKind.image,
|
||||
purpose: MediaPurpose.postImage,
|
||||
mimeType: 'image/jpeg',
|
||||
byteSize: 204800,
|
||||
),
|
||||
);
|
||||
|
||||
final request = adapter.requests.single;
|
||||
expect(request.path, '/api/v1/media/uploads');
|
||||
expect(request.method, 'POST');
|
||||
expect(request.headers['Authorization'], 'Bearer community-access');
|
||||
expect(request.data, {
|
||||
'kind': 'image',
|
||||
'purpose': 'post_image',
|
||||
'mimeType': 'image/jpeg',
|
||||
'byteSize': 204800,
|
||||
});
|
||||
expect(credentials.uploadUrl, contains('X-Amz-Signature'));
|
||||
});
|
||||
|
||||
test('completeMediaUpload:POST /complete 无请求体(服务端幂等)', () async {
|
||||
await setUpWith(
|
||||
(options) async =>
|
||||
jsonResponse(200, okEnvelope(sampleMediaAssetJson())),
|
||||
);
|
||||
|
||||
final asset = await repository.completeMediaUpload('a-1');
|
||||
|
||||
final request = adapter.requests.single;
|
||||
expect(request.path, '/api/v1/media/uploads/a-1/complete');
|
||||
expect(request.method, 'POST');
|
||||
expect(asset.status, MediaAssetStatus.ready);
|
||||
});
|
||||
|
||||
test('帖子 CRUD:POST / GET / PATCH / DELETE 线路', () async {
|
||||
var call = 0;
|
||||
await setUpWith((options) async {
|
||||
call += 1;
|
||||
return switch (call) {
|
||||
1 => jsonResponse(201, okEnvelope(samplePostJson())),
|
||||
2 => jsonResponse(200, okEnvelope(samplePostJson())),
|
||||
3 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))),
|
||||
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
|
||||
};
|
||||
});
|
||||
|
||||
await repository.createPost(const CreatePostRequest(content: '正文'));
|
||||
await repository.getPost('p-1');
|
||||
final updated = await repository.updatePost(
|
||||
'p-1',
|
||||
const UpdatePostRequest(version: 1, publish: true),
|
||||
);
|
||||
await repository.deletePost('p-1');
|
||||
|
||||
expect(adapter.requests[0].path, '/api/v1/posts');
|
||||
expect(adapter.requests[0].method, 'POST');
|
||||
expect(adapter.requests[1].path, '/api/v1/posts/p-1');
|
||||
expect(adapter.requests[1].method, 'GET');
|
||||
expect(adapter.requests[2].method, 'PATCH');
|
||||
expect(adapter.requests[2].data, {'version': 1, 'status': 'published'});
|
||||
expect(adapter.requests[3].method, 'DELETE');
|
||||
expect(updated.version, 2);
|
||||
});
|
||||
|
||||
test('listMyPosts:/api/v1/me/posts 分页 + status 过滤,缺省不传', () async {
|
||||
await setUpWith(
|
||||
(options) async => jsonResponse(
|
||||
200,
|
||||
okEnvelope(
|
||||
cursorPageJson([samplePostJson()], nextCursor: 'c2', hasMore: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final page = await repository.listMyPosts(
|
||||
limit: 20,
|
||||
cursor: 'c1',
|
||||
status: PostStatus.draft,
|
||||
);
|
||||
await repository.listMyPosts();
|
||||
|
||||
expect(adapter.requests[0].path, '/api/v1/me/posts');
|
||||
expect(adapter.requests[0].queryParameters, {
|
||||
'limit': 20,
|
||||
'cursor': 'c1',
|
||||
'status': 'draft',
|
||||
});
|
||||
expect(adapter.requests[1].queryParameters, isEmpty);
|
||||
expect(page.items.single.id, 'p-1');
|
||||
expect(page.nextCursor, 'c2');
|
||||
expect(page.hasMore, isTrue);
|
||||
});
|
||||
|
||||
test('getFeed:/api/v1/feed 游标分页与 FeedCard 信封解析', () async {
|
||||
await setUpWith(
|
||||
(options) async => jsonResponse(
|
||||
200,
|
||||
okEnvelope(
|
||||
cursorPageJson(
|
||||
[sampleFeedCardJson()],
|
||||
nextCursor: 'f2',
|
||||
hasMore: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final page = await repository.getFeed(limit: 20, cursor: 'f1');
|
||||
|
||||
final request = adapter.requests.single;
|
||||
expect(request.path, '/api/v1/feed');
|
||||
expect(request.queryParameters, {'limit': 20, 'cursor': 'f1'});
|
||||
expect(page.items.single.contentPreview, '晒了一下午太阳。');
|
||||
expect(page.nextCursor, 'f2');
|
||||
});
|
||||
|
||||
test('评论:列表分页、创建、顶层短路径删除', () async {
|
||||
var call = 0;
|
||||
await setUpWith((options) async {
|
||||
call += 1;
|
||||
return switch (call) {
|
||||
1 => jsonResponse(
|
||||
200,
|
||||
okEnvelope(cursorPageJson([sampleCommentJson()])),
|
||||
),
|
||||
2 => jsonResponse(201, okEnvelope(sampleCommentJson())),
|
||||
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
|
||||
};
|
||||
});
|
||||
|
||||
final page = await repository.listComments('p-1', limit: 20);
|
||||
await repository.createComment(
|
||||
'p-1',
|
||||
const CreateCommentRequest(content: '好可爱!'),
|
||||
);
|
||||
await repository.deleteComment('c-1');
|
||||
|
||||
expect(adapter.requests[0].path, '/api/v1/posts/p-1/comments');
|
||||
expect(adapter.requests[0].queryParameters, {'limit': 20});
|
||||
expect(adapter.requests[1].method, 'POST');
|
||||
expect(adapter.requests[1].data, {'content': '好可爱!'});
|
||||
expect(adapter.requests[2].path, '/api/v1/comments/c-1');
|
||||
expect(adapter.requests[2].method, 'DELETE');
|
||||
expect(page.items.single.content, '好可爱!');
|
||||
expect(page.hasMore, isFalse);
|
||||
});
|
||||
|
||||
test('点赞/收藏:PUT 与 DELETE 同路径,返回权威终态', () async {
|
||||
var call = 0;
|
||||
await setUpWith((options) async {
|
||||
call += 1;
|
||||
return switch (call) {
|
||||
1 => jsonResponse(200, okEnvelope({'liked': true, 'likeCount': 7})),
|
||||
2 => jsonResponse(200, okEnvelope({'liked': false, 'likeCount': 6})),
|
||||
3 => jsonResponse(
|
||||
200,
|
||||
okEnvelope({'bookmarked': true, 'bookmarkCount': 3}),
|
||||
),
|
||||
_ => jsonResponse(
|
||||
200,
|
||||
okEnvelope({'bookmarked': false, 'bookmarkCount': 2}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
final liked = await repository.likePost('p-1');
|
||||
final unliked = await repository.unlikePost('p-1');
|
||||
final bookmarked = await repository.bookmarkPost('p-1');
|
||||
await repository.unbookmarkPost('p-1');
|
||||
|
||||
expect(adapter.requests[0].path, '/api/v1/posts/p-1/like');
|
||||
expect(adapter.requests[0].method, 'PUT');
|
||||
expect(adapter.requests[1].path, '/api/v1/posts/p-1/like');
|
||||
expect(adapter.requests[1].method, 'DELETE');
|
||||
expect(adapter.requests[2].path, '/api/v1/posts/p-1/bookmark');
|
||||
expect(adapter.requests[2].method, 'PUT');
|
||||
expect(adapter.requests[3].method, 'DELETE');
|
||||
// PUT/DELETE 语义幂等:无 Idempotency-Key。
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
|
||||
}
|
||||
expect(liked.liked, isTrue);
|
||||
expect(liked.likeCount, 7);
|
||||
expect(unliked.liked, isFalse);
|
||||
expect(bookmarked.bookmarkCount, 3);
|
||||
});
|
||||
|
||||
test('listMyBookmarks:/api/v1/me/bookmarks,项形态 = FeedCard', () async {
|
||||
await setUpWith(
|
||||
(options) async => jsonResponse(
|
||||
200,
|
||||
okEnvelope(cursorPageJson([sampleFeedCardJson()])),
|
||||
),
|
||||
);
|
||||
|
||||
final page = await repository.listMyBookmarks(cursor: 'b1');
|
||||
|
||||
expect(adapter.requests.single.path, '/api/v1/me/bookmarks');
|
||||
expect(adapter.requests.single.queryParameters, {'cursor': 'b1'});
|
||||
expect(page.items.single.id, 'p-1');
|
||||
});
|
||||
|
||||
test('关注:PUT / DELETE / follow-stats 线路与终态', () async {
|
||||
var call = 0;
|
||||
await setUpWith((options) async {
|
||||
call += 1;
|
||||
return switch (call) {
|
||||
1 => jsonResponse(
|
||||
200,
|
||||
okEnvelope({'following': true, 'followerCount': 12}),
|
||||
),
|
||||
2 => jsonResponse(
|
||||
200,
|
||||
okEnvelope({'following': false, 'followerCount': 11}),
|
||||
),
|
||||
_ => jsonResponse(
|
||||
200,
|
||||
okEnvelope({
|
||||
'followerCount': 11,
|
||||
'followingCount': 34,
|
||||
'followedByMe': false,
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
final followed = await repository.followUser('u-2');
|
||||
final unfollowed = await repository.unfollowUser('u-2');
|
||||
final stats = await repository.getFollowStats('u-2');
|
||||
|
||||
expect(adapter.requests[0].path, '/api/v1/users/u-2/follow');
|
||||
expect(adapter.requests[0].method, 'PUT');
|
||||
expect(adapter.requests[1].method, 'DELETE');
|
||||
expect(adapter.requests[2].path, '/api/v1/users/u-2/follow-stats');
|
||||
expect(adapter.requests[2].method, 'GET');
|
||||
expect(followed.following, isTrue);
|
||||
expect(unfollowed.followerCount, 11);
|
||||
expect(stats.followingCount, 34);
|
||||
});
|
||||
});
|
||||
|
||||
group('Idempotency-Key(community 域必带语义)', () {
|
||||
test('createPost / createComment 必带键,每次逻辑提交换新键', () async {
|
||||
var call = 0;
|
||||
await setUpWith((options) async {
|
||||
call += 1;
|
||||
return call <= 2
|
||||
? jsonResponse(201, okEnvelope(samplePostJson()))
|
||||
: jsonResponse(201, okEnvelope(sampleCommentJson()));
|
||||
});
|
||||
|
||||
await repository.createPost(const CreatePostRequest(content: '一'));
|
||||
await repository.createPost(const CreatePostRequest(content: '二'));
|
||||
await repository.createComment(
|
||||
'p-1',
|
||||
const CreateCommentRequest(content: '三'),
|
||||
);
|
||||
|
||||
final keys = adapter.requests
|
||||
.map((r) => r.headers['Idempotency-Key'] as String?)
|
||||
.toList();
|
||||
expect(keys, everyElement(isNotNull));
|
||||
expect(keys, everyElement(isNotEmpty));
|
||||
// 每次逻辑提交换新键(契约:1~128 字符,建议 UUID)。
|
||||
expect(keys.toSet().length, 3);
|
||||
expect(keys.every((key) => key!.length <= 128), isTrue);
|
||||
});
|
||||
|
||||
test('GET / PATCH / DELETE 不带 Idempotency-Key', () async {
|
||||
var call = 0;
|
||||
await setUpWith((options) async {
|
||||
call += 1;
|
||||
return switch (call) {
|
||||
1 => jsonResponse(200, okEnvelope(samplePostJson())),
|
||||
2 => jsonResponse(200, okEnvelope(samplePostJson(version: 2))),
|
||||
_ => jsonResponse(200, {'code': 0, 'message': 'ok', 'data': null}),
|
||||
};
|
||||
});
|
||||
|
||||
await repository.getPost('p-1');
|
||||
await repository.updatePost('p-1', const UpdatePostRequest(version: 1));
|
||||
await repository.deletePost('p-1');
|
||||
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
|
||||
}
|
||||
});
|
||||
|
||||
test('40101:单飞刷新后重放,重放沿用同一 Idempotency-Key', () async {
|
||||
await setUpWith((options) async {
|
||||
if (options.path == '/api/v1/auth/refresh') {
|
||||
return jsonResponse(
|
||||
200,
|
||||
okEnvelope(tokenDataJson(access: 'new-access')),
|
||||
);
|
||||
}
|
||||
if (options.headers['Authorization'] == 'Bearer community-access') {
|
||||
return jsonResponse(401, errorEnvelope(40101, 'token 过期'));
|
||||
}
|
||||
return jsonResponse(201, okEnvelope(samplePostJson()));
|
||||
});
|
||||
|
||||
await repository.createPost(const CreatePostRequest(content: '正文'));
|
||||
|
||||
final postRequests = adapter.requests
|
||||
.where((r) => r.path == '/api/v1/posts')
|
||||
.toList();
|
||||
expect(postRequests, hasLength(2));
|
||||
expect(postRequests.last.headers['Authorization'], 'Bearer new-access');
|
||||
// 刷新重放是同一逻辑提交:同键命中服务端首次结果,不重复建帖。
|
||||
expect(
|
||||
postRequests.first.headers['Idempotency-Key'],
|
||||
postRequests.last.headers['Idempotency-Key'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('错误码 → 类型化异常映射(v1.3.0 新增 9 码 + 40902)', () {
|
||||
Future<void> expectMapped(int httpStatus, int code, Matcher matcher) async {
|
||||
await setUpWith(
|
||||
(options) async => jsonResponse(httpStatus, errorEnvelope(code)),
|
||||
);
|
||||
await expectLater(repository.getPost('p-x'), throwsA(matcher));
|
||||
}
|
||||
|
||||
test('40301 → PostAccessDeniedException', () async {
|
||||
await expectMapped(403, 40301, isA<PostAccessDeniedException>());
|
||||
});
|
||||
|
||||
test('40403 → PostNotFoundException(防枚举合并)', () async {
|
||||
await expectMapped(404, 40403, isA<PostNotFoundException>());
|
||||
});
|
||||
|
||||
test('40404 → CommentNotFoundException', () async {
|
||||
await expectMapped(404, 40404, isA<CommentNotFoundException>());
|
||||
});
|
||||
|
||||
test('40405 → MediaAssetNotFoundException', () async {
|
||||
await expectMapped(404, 40405, isA<MediaAssetNotFoundException>());
|
||||
});
|
||||
|
||||
test('40406 → CommunityUserNotFoundException', () async {
|
||||
await expectMapped(404, 40406, isA<CommunityUserNotFoundException>());
|
||||
});
|
||||
|
||||
test('40902 → PostVersionConflictException(共码独立类型)', () async {
|
||||
await expectMapped(409, 40902, isA<PostVersionConflictException>());
|
||||
});
|
||||
|
||||
test('40905 → IdempotencyMismatchException', () async {
|
||||
await expectMapped(409, 40905, isA<IdempotencyMismatchException>());
|
||||
});
|
||||
|
||||
test('42203 → MediaNotReadyException', () async {
|
||||
await expectMapped(422, 42203, isA<MediaNotReadyException>());
|
||||
});
|
||||
|
||||
test('42204 → SelfFollowException', () async {
|
||||
await expectMapped(422, 42204, isA<SelfFollowException>());
|
||||
});
|
||||
|
||||
test('42205 → MediaUploadStateException', () async {
|
||||
await expectMapped(422, 42205, isA<MediaUploadStateException>());
|
||||
});
|
||||
|
||||
test('40401(pets 域码)不升格,保持通用 ApiBusinessException', () async {
|
||||
await setUpWith(
|
||||
(options) async => jsonResponse(404, errorEnvelope(40401, '宠物不存在')),
|
||||
);
|
||||
await expectLater(
|
||||
repository.createPost(const CreatePostRequest(content: '带宠物')),
|
||||
throwsA(
|
||||
isA<ApiBusinessException>()
|
||||
.having((e) => e.code, 'code', 40401)
|
||||
.having((e) => e, 'type', isNot(isA<PostNotFoundException>())),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('类型化异常仍可按基类 ApiBusinessException 捕获', () {
|
||||
const error = SelfFollowException(message: '不能关注自己');
|
||||
expect(error, isA<ApiBusinessException>());
|
||||
expect(error.code, ApiCodes.selfFollow);
|
||||
});
|
||||
});
|
||||
|
||||
test('community 服务基地址常量存在且默认指向 :8084', () {
|
||||
expect(patbondCommunityApiBaseUrl, 'http://127.0.0.1:8084');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user