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>
147 lines
5.9 KiB
Dart
147 lines
5.9 KiB
Dart
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 PNG(67 字节,合法图片本体;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');
|
||
}
|