Files
patbond-flutter/test/features/community/community_repository_test.dart
T
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

435 lines
16 KiB
Dart
Raw 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: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('createMediaUploadPOST /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('completeMediaUploadPOST /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('帖子 CRUDPOST / 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-Keycommunity 域必带语义)', () {
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('40401pets 域码)不升格,保持通用 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');
});
}