Files
patbond-flutter/test/smoke/media_upload_smoke_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

147 lines
5.9 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 'dart:async';
import 'dart:convert';
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/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_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_compression.dart';
import 'package:patbond_flutter/features/community/media_picking.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:uuid/uuid.dart';
import '../helpers/auth_test_helpers.dart';
/// T3-13 compose 真链路冒烟(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根):
/// # ./deploy/init-secrets.sh
/// # JAVA_HOME=<JDK17> ./mvnw -DskipTests package && docker compose up -d --build
/// PATBOND_MEDIA_SMOKE=1 flutter test test/smoke/media_upload_smoke_test.dart
/// ```
///
/// 驱动**真实 MediaUploader** 走完整链路:注册取 token → createUpload
/// user :8082)→ 预签名 PUT 直传 MinIO:9000)→ confirm → ready
/// assetId → 引用发帖(community :8084)→ 预签名 GET 取回字节一致。
/// 压缩层用透传实现(flutter test VM 无原生编解码通道),其余全为生产实现。
void main() {
final enabled = Platform.environment['PATBOND_MEDIA_SMOKE'] == '1';
final env = Platform.environment;
final authBase = env['PATBOND_SMOKE_AUTH_BASE'] ?? 'http://127.0.0.1:8081';
final userBase = env['PATBOND_SMOKE_USER_BASE'] ?? 'http://127.0.0.1:8082';
final communityBase =
env['PATBOND_SMOKE_COMMUNITY_BASE'] ?? 'http://127.0.0.1:8084';
// 1x1 PNG67 字节,合法图片本体;mime 声明与直传 Content-Type 一致)。
final pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8'
'z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
);
test(
'选图→压缩→createUpload→直传→confirm→ready→引用发帖→GET 回读',
() 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': 'smoke$seed',
'phone': '+86139${(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 repository = ApiCommunityRepository(
api: ApiClient(
dio: buildPatbondDio(session: session, baseUrl: communityBase),
session: session,
refresher: refresher,
),
mediaApi: ApiClient(
dio: buildPatbondDio(session: session, baseUrl: userBase),
session: session,
refresher: refresher,
),
);
// ---- 真实 MediaUploader 全链路 ----
final uploader = MediaUploader(
repository: repository,
compressor: _PassthroughPngCompressor(),
);
final done = Completer<void>();
uploader.addListener(() {
if (done.isCompleted) return;
if (uploader.allReady) done.complete();
if (uploader.hasFailure) {
done.completeError(
StateError('上传失败:${uploader.items.single.errorMessage}'),
);
}
});
uploader.addImages([
PickedMediaImage(bytes: pngBytes, name: 'smoke.png'),
]);
await done.future.timeout(const Duration(seconds: 60));
final item = uploader.items.single;
expect(item.phase, MediaItemPhase.ready);
expect(item.assetId, isNotEmpty);
// ---- ready assetId 引用发帖(孤儿防护出口)+ 预签名 GET 回读 ----
final post = await repository.createPost(
CreatePostRequest(
content: 'T3-13 媒体上传冒烟 $seed',
status: PostStatus.published,
media: uploader.buildAttachRequests(),
),
);
expect(post.media.single.assetId, item.assetId);
final fetched = await dio.get<List<int>>(
post.media.single.url,
options: Options(responseType: ResponseType.bytes),
);
expect(fetched.statusCode, 200);
expect(fetched.data, pngBytes, reason: '预签名 GET 回读字节应与上传一致');
// 收尾:删除冒烟帖(asset 服务端软删语义随帖处理)。
await repository.deletePost(post.id);
},
skip: enabled ? false : '需 compose 后端在位,PATBOND_MEDIA_SMOKE=1 时执行',
timeout: const Timeout(Duration(minutes: 3)),
);
}
/// 透传压缩器:原样输出字节并声明 image/png(仅冒烟用——测试 VM 无
/// flutter_image_compress 平台通道;生产走 NativeMediaImageCompressor)。
class _PassthroughPngCompressor implements MediaImageCompressor {
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
}