Files
patbond-flutter/test/features/community/community_repository_test.dart
T
lixi 1441f0148f
CI / flutter-gates (push) Successful in 2m30s
新增:媒体上传客户端——MediaUploader 编排状态机 + 预签名直传 + 进度组件,修正 media 端点误挂 community 服务(T3-13)
- MediaUploader:选图→压缩(80→60 降质阶梯,10 MiB 超限拒绝)→createUpload
  →预签名 PUT 直传(进度回调)→confirm→ready assetId 交付;并发槽位 2、
  顺序即 position、单图失败不拖垮整批;凭据过期预检与存储侧 403 各自动
  换新凭据一次;失败重试复用压缩产物、从 createUpload 全新开始
- 孤儿防护:未 confirm 的 assetId 只以管线局部变量存在,快照 assetId 与
  ready 态断言绑定,buildAttachRequests 非全员 ready 即抛 StateError
- 线路修正:media 两步上传端点由 user 服务(:8082 MediaController)提供,
  T3-12 误挂 community 客户端(:8084 无 media 路由),补 mediaApi 分端口
  直连,compose 真链路实测证实
- UploadProgressOverlay 四态进度层(05 号规范 §3.3);直传层本地
  HttpServer 三线路测试(200/403/断连);compose 冒烟测试
  (PATBOND_MEDIA_SMOKE=1 启用,默认跳过)
- 新依赖:image_picker、flutter_image_compress(03 号评估选型)
- flutter test 379 全绿(基线 347,+32)、analyze 0、format 无 diff

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 12:21:20 +08:00

456 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 FakeHttpAdapter mediaAdapter;
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);
// media 两步上传端点由 user 服务提供(13 号报告 §2),走独立客户端;
// 两 adapter 分开记录以断言线路不串。
final mediaDio = buildPatbondDio(
session: session,
baseUrl: 'http://user.local',
);
mediaAdapter = FakeHttpAdapter(handler);
mediaDio.httpClientAdapter = mediaAdapter;
repository = ApiCommunityRepository(
api: ApiClient(dio: dio, session: session, refresher: refresher),
mediaApi: ApiClient(
dio: mediaDio,
session: session,
refresher: refresher,
),
);
}
group('请求线路(路径 / 方法 / 鉴权 / 参数)', () {
test(
'createMediaUploadPOST /api/v1/media/uploads 走 user 服务客户端,携带 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,
),
);
// 线路:media 端点不打 community 客户端。
expect(adapter.requests, isEmpty);
final request = mediaAdapter.requests.single;
expect(request.baseUrl, 'http://user.local');
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');
expect(adapter.requests, isEmpty);
final request = mediaAdapter.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');
});
}