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= ./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>( '$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), ); 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(); 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>( 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 compress( PickedMediaImage source, { required int quality, }) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png'); }