import 'dart:async'; import 'dart:io'; 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/auth_models.dart'; import 'package:patbond_flutter/features/auth/session_manager.dart'; import 'package:patbond_flutter/features/community/community_controller.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/community_repository.dart'; import 'package:uuid/uuid.dart'; import '../helpers/auth_test_helpers.dart'; /// T3-15/16 compose 真链路冒烟(默认跳过,不计入常规测试套件): /// /// ```bash /// # 先起后端六容器(patbond-api 仓库根): /// # ./deploy/init-secrets.sh /// # JAVA_HOME= ./mvnw -DskipTests package && docker compose up -d --build /// PATBOND_DETAIL_SMOKE=1 flutter test test/smoke/detail_interactions_smoke_test.dart /// ``` /// /// 覆盖:发帖 → 点赞(含重复施加幂等)→ 收藏/取消 → 评论创建 /// (Idempotency-Key)→ 仅作者删除评论 → 计数权威对账;再走一轮 /// **断网点赞回滚**——[CommunityController]/ToggleSync 生产实现 + /// 生产 ApiClient 错误链,community 端点切至不可达端口模拟断网 /// (连接拒绝走真实 ApiNetworkException 路径),断言乐观翻转即刻 /// 可见、失败后快照回滚、网络恢复后权威终态收敛。 void main() { final enabled = Platform.environment['PATBOND_DETAIL_SMOKE'] == '1'; final env = Platform.environment; final authBase = env['PATBOND_SMOKE_AUTH_BASE'] ?? 'http://127.0.0.1:8081'; final communityBase = env['PATBOND_SMOKE_COMMUNITY_BASE'] ?? 'http://127.0.0.1:8084'; test( '点赞/收藏/评论/删评真链路一轮 + 断网点赞回滚', () async { // ---- 注册一次性账号(随机凭据,不落任何持久化)---- final dio = Dio(BaseOptions(validateStatus: (_) => true)); final seed = DateTime.now().millisecondsSinceEpoch; final register = await dio.post>( '$authBase/api/v1/auth/register', data: { 'username': 'ismoke$seed', 'phone': '+86138${(seed % 100000000).toString().padLeft(8, '0')}', 'password': 'Smoke1234!$seed', }, options: Options( headers: { 'Idempotency-Key': const Uuid().v4(), 'X-Device-Id': const Uuid().v4(), }, ), ); expect(register.data?['code'], 0, reason: '注册失败:${register.data}'); final session = SessionManager(store: InMemoryTokenStore()); await session.updateTokens( AuthTokens.fromJson(register.data!['data'] as Map), ); final refresher = TokenRefresher( dio: buildPatbondDio(session: session, baseUrl: authBase), session: session, ); final live = ApiCommunityRepository( api: ApiClient( dio: buildPatbondDio(session: session, baseUrl: communityBase), session: session, refresher: refresher, ), ); // 「断网」形态:community 域切至不可达端口(连接拒绝 → 生产 // ApiClient 映射 ApiNetworkException,与真实断网同一异常链)。 final dead = ApiCommunityRepository( api: ApiClient( dio: buildPatbondDio(session: session, baseUrl: 'http://127.0.0.1:9'), session: session, refresher: refresher, ), ); final switchable = _SwitchableRepository(live); // ---- 发帖(published,纯文字)---- final post = await live.createPost( CreatePostRequest( content: 'T3-15/16 互动冒烟 $seed', status: PostStatus.published, ), ); expect(post.likeCount, 0); expect(post.commentCount, 0); // ---- 点赞:施加 / 重复施加幂等,权威终态 ---- var like = await live.likePost(post.id); expect(like.liked, true); expect(like.likeCount, 1); like = await live.likePost(post.id); expect(like.likeCount, 1, reason: '重复 PUT 不重复计数'); // ---- 收藏 / 取消 ---- var bookmark = await live.bookmarkPost(post.id); expect(bookmark.bookmarked, true); expect(bookmark.bookmarkCount, 1); bookmark = await live.unbookmarkPost(post.id); expect(bookmark.bookmarked, false); expect(bookmark.bookmarkCount, 0); // ---- 评论创建(Idempotency-Key 由仓库层携带)→ 计数 +1 ---- final comment = await live.createComment( post.id, const CreateCommentRequest(content: '冒烟评论:真链路一轮'), ); expect(comment.content, '冒烟评论:真链路一轮'); var fresh = await live.getPost(post.id); expect(fresh.commentCount, 1); // ---- 仅作者删除评论 → 计数 -1、列表剔除 ---- await live.deleteComment(comment.id); fresh = await live.getPost(post.id); expect(fresh.commentCount, 0); final comments = await live.listComments(post.id); expect(comments.items, isEmpty); // ---- 断网点赞回滚(生产 CommunityController + ToggleSync)---- final controller = CommunityController(repository: switchable); await controller.refresh(); FeedCard card() => controller.feed.firstWhere((c) => c.id == post.id); expect(card().likedByMe, true); expect(card().likeCount, 1); switchable.target = dead; // 拔网线。 final errored = Completer(); controller.addListener(() { if (controller.toggleError != null && !errored.isCompleted) { errored.complete(); } }); controller.toggleLike(post.id); // 乐观翻转同帧可见。 expect(card().likedByMe, false); expect(card().likeCount, 0); await errored.future.timeout(const Duration(seconds: 30)); // 快照回滚成对恢复 + 一次性错误可供 SnackBar 消费。 expect(card().likedByMe, true); expect(card().likeCount, 1); expect(controller.toggleError, isA()); controller.clearToggleError(); // ---- 网络恢复:取消点赞收敛到服务端权威终态 ---- switchable.target = live; controller.toggleLike(post.id); expect(card().likedByMe, false); // 乐观翻转。 // 轮询服务端权威终态(乐观值不作为收敛依据)。 final deadline = DateTime.now().add(const Duration(seconds: 15)); Post settled = await live.getPost(post.id); while (settled.likedByMe) { expect(DateTime.now().isBefore(deadline), true, reason: '收敛超时'); await Future.delayed(const Duration(milliseconds: 300)); settled = await live.getPost(post.id); } expect(settled.likedByMe, false); expect(settled.likeCount, 0); expect(controller.toggleError, isNull); expect(card().likedByMe, false); controller.dispose(); }, skip: enabled ? false : '设 PATBOND_DETAIL_SMOKE=1 且后端六容器在本机运行时才执行', timeout: const Timeout(Duration(minutes: 3)), ); } /// 可切换目标的仓库代理(断网模拟:live ↔ dead 端点整体切换)。 class _SwitchableRepository implements CommunityRepository { _SwitchableRepository(this.target); CommunityRepository target; @override Future createMediaUpload( CreateMediaUploadRequest request, ) => target.createMediaUpload(request); @override Future completeMediaUpload(String assetId) => target.completeMediaUpload(assetId); @override Future createPost( CreatePostRequest request, { String? idempotencyKey, }) => target.createPost(request, idempotencyKey: idempotencyKey); @override Future getPost(String postId) => target.getPost(postId); @override Future updatePost(String postId, UpdatePostRequest request) => target.updatePost(postId, request); @override Future deletePost(String postId) => target.deletePost(postId); @override Future> listMyPosts({ int? limit, String? cursor, PostStatus? status, }) => target.listMyPosts(limit: limit, cursor: cursor, status: status); @override Future> getFeed({int? limit, String? cursor}) => target.getFeed(limit: limit, cursor: cursor); @override Future> listComments( String postId, { int? limit, String? cursor, }) => target.listComments(postId, limit: limit, cursor: cursor); @override Future createComment( String postId, CreateCommentRequest request, ) => target.createComment(postId, request); @override Future deleteComment(String commentId) => target.deleteComment(commentId); @override Future likePost(String postId) => target.likePost(postId); @override Future unlikePost(String postId) => target.unlikePost(postId); @override Future bookmarkPost(String postId) => target.bookmarkPost(postId); @override Future unbookmarkPost(String postId) => target.unbookmarkPost(postId); @override Future> listMyBookmarks({int? limit, String? cursor}) => target.listMyBookmarks(limit: limit, cursor: cursor); @override Future followUser(String userId) => target.followUser(userId); @override Future unfollowUser(String userId) => target.unfollowUser(userId); @override Future getFollowStats(String userId) => target.getFollowStats(userId); }