Files
patbond-flutter/test/smoke/detail_interactions_smoke_test.dart
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

266 lines
9.7 KiB
Dart
Raw Permalink 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 '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=<JDK17> ./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<Map<String, dynamic>>(
'$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<String, dynamic>),
);
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<void>();
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<ApiNetworkException>());
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<void>.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<MediaUploadCredentials> createMediaUpload(
CreateMediaUploadRequest request,
) => target.createMediaUpload(request);
@override
Future<MediaAsset> completeMediaUpload(String assetId) =>
target.completeMediaUpload(assetId);
@override
Future<Post> createPost(
CreatePostRequest request, {
String? idempotencyKey,
}) => target.createPost(request, idempotencyKey: idempotencyKey);
@override
Future<Post> getPost(String postId) => target.getPost(postId);
@override
Future<Post> updatePost(String postId, UpdatePostRequest request) =>
target.updatePost(postId, request);
@override
Future<void> deletePost(String postId) => target.deletePost(postId);
@override
Future<CursorPage<Post>> listMyPosts({
int? limit,
String? cursor,
PostStatus? status,
}) => target.listMyPosts(limit: limit, cursor: cursor, status: status);
@override
Future<CursorPage<FeedCard>> getFeed({int? limit, String? cursor}) =>
target.getFeed(limit: limit, cursor: cursor);
@override
Future<CursorPage<PostComment>> listComments(
String postId, {
int? limit,
String? cursor,
}) => target.listComments(postId, limit: limit, cursor: cursor);
@override
Future<PostComment> createComment(
String postId,
CreateCommentRequest request,
) => target.createComment(postId, request);
@override
Future<void> deleteComment(String commentId) =>
target.deleteComment(commentId);
@override
Future<LikeState> likePost(String postId) => target.likePost(postId);
@override
Future<LikeState> unlikePost(String postId) => target.unlikePost(postId);
@override
Future<BookmarkState> bookmarkPost(String postId) =>
target.bookmarkPost(postId);
@override
Future<BookmarkState> unbookmarkPost(String postId) =>
target.unbookmarkPost(postId);
@override
Future<CursorPage<FeedCard>> listMyBookmarks({int? limit, String? cursor}) =>
target.listMyBookmarks(limit: limit, cursor: cursor);
@override
Future<FollowState> followUser(String userId) => target.followUser(userId);
@override
Future<FollowState> unfollowUser(String userId) =>
target.unfollowUser(userId);
@override
Future<FollowStats> getFollowStats(String userId) =>
target.getFollowStats(userId);
}