1441f0148f
CI / flutter-gates (push) Successful in 2m30s
- 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>
456 lines
16 KiB
Dart
456 lines
16 KiB
Dart
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(
|
||
'createMediaUpload:POST /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('completeMediaUpload:POST /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('帖子 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');
|
||
});
|
||
}
|