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 setUpWith( Future 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 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()); }); test('40403 → PostNotFoundException(防枚举合并)', () async { await expectMapped(404, 40403, isA()); }); test('40404 → CommentNotFoundException', () async { await expectMapped(404, 40404, isA()); }); test('40405 → MediaAssetNotFoundException', () async { await expectMapped(404, 40405, isA()); }); test('40406 → CommunityUserNotFoundException', () async { await expectMapped(404, 40406, isA()); }); test('40902 → PostVersionConflictException(共码独立类型)', () async { await expectMapped(409, 40902, isA()); }); test('40905 → IdempotencyMismatchException', () async { await expectMapped(409, 40905, isA()); }); test('42203 → MediaNotReadyException', () async { await expectMapped(422, 42203, isA()); }); test('42204 → SelfFollowException', () async { await expectMapped(422, 42204, isA()); }); test('42205 → MediaUploadStateException', () async { await expectMapped(422, 42205, isA()); }); test('40401(pets 域码)不升格,保持通用 ApiBusinessException', () async { await setUpWith( (options) async => jsonResponse(404, errorEnvelope(40401, '宠物不存在')), ); await expectLater( repository.createPost(const CreatePostRequest(content: '带宠物')), throwsA( isA() .having((e) => e.code, 'code', 40401) .having((e) => e, 'type', isNot(isA())), ), ); }); test('类型化异常仍可按基类 ApiBusinessException 捕获', () { const error = SelfFollowException(message: '不能关注自己'); expect(error, isA()); expect(error.code, ApiCodes.selfFollow); }); }); test('community 服务基地址常量存在且默认指向 :8084', () { expect(patbondCommunityApiBaseUrl, 'http://127.0.0.1:8084'); }); }