import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:patbond_flutter/app/app.dart'; import 'package:patbond_flutter/core/widgets/post_card.dart'; import 'package:patbond_flutter/features/auth/session_manager.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'; /// 内存 token 存储(桌面实测环境无 keyring;不落任何持久化)。 class _InMemoryTokenStore implements TokenStore { final Map _values = {}; @override Future read(String key) async => _values[key]; @override Future write(String key, String value) async => _values[key] = value; @override Future delete(String key) async => _values.remove(key); } /// 1x1 真 PNG(70B):桌面实测的「选中的照片」。 final Uint8List _pngBytes = base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAF' 'AAH/q842iQAAAABJRU5ErkJggg==', ); /// 桌面选图替身:Linux 桌面无 image_picker 平台实现,实测注入字节。 class _DesktopPicker implements MediaImagePicker { @override Future> pickImages({required int limit}) async => [ PickedMediaImage(bytes: _pngBytes, name: 'live.png'), ]; } /// 桌面压缩替身:Linux 桌面无 flutter_image_compress 原生实现,原样透传 /// (mime 落 image/png,在服务端白名单内)。其余环节全为生产实现。 class _PassthroughCompressor implements MediaImageCompressor { @override Future compress( PickedMediaImage source, { required int quality, }) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png'); } /// T3-17 发布链路桌面真链路实测(默认跳过,不计入常规测试套件): /// /// ```bash /// # 先起后端六容器(patbond-api 仓库根:docker compose up -d --build),再: /// PATBOND_PUBLISH_LIVE=1 flutter test integration_test/publish_live_test.dart -d linux /// ``` /// /// 驱动**真实 App**(Linux 桌面渲染 + 真实 HTTP + MinIO 预签名直传): /// 注册两个一次性账号 → A 登录 → 创作 Tab「发布动态」→ 输入正文 → 选图 /// 上传(真 createUpload / 真预签名 PUT / 真 confirm)→ 发布(建草稿 → /// PATCH 迁移)→ 回首页 Feed 新帖置顶可见 → **另起一个全新 App 实例 /// (新会话/新控制器/新 HTTP 客户端)以 B 账号登录,B 的 Feed 同样看到 /// 该帖**(M3 验收「发布后可在另一客户端看到」取证)。 /// /// 桌面替身仅两处:选图与压缩(Linux 无这两个插件的平台实现)。 /// 注意:桌面 `platform` 值为 `linux`,不在契约枚举内 → 埋点批次被服务端 /// 400 整批拒绝(既有预期行为,见 analytics_service.dart 注释);埋点落库 /// 取证走 curl(26 号报告 §5c)。 void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; final enabled = Platform.environment['PATBOND_PUBLISH_LIVE'] == '1'; const authBase = 'http://127.0.0.1:8081'; Future pumpUntil( WidgetTester tester, Finder finder, { Duration timeout = const Duration(seconds: 30), }) async { final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { await tester.pump(const Duration(milliseconds: 250)); if (finder.evaluate().isNotEmpty) return; } fail('等待超时:$finder'); } Future register(String username, String password, int seed) async { final client = HttpClient(); final request = await client.postUrl( Uri.parse('$authBase/api/v1/auth/register'), ); request.headers.contentType = ContentType.json; request.add( utf8.encode( jsonEncode({ 'username': username, 'phone': '+86137${(seed % 100000000).toString().padLeft(8, '0')}', 'password': password, }), ), ); final response = await request.close(); expect(response.statusCode, 200, reason: '注册测试账号失败:$username'); client.close(); } Future login( WidgetTester tester, String username, String password, ) async { await pumpUntil(tester, find.text('登录')); await tester.enterText(find.byType(TextField).at(0), username); await tester.enterText(find.byType(TextField).at(1), password); await tester.tap(find.text('登录')); } testWidgets('发布桌面真链路:选图上传 → 发布 → Feed 置顶 → 第二账号可见', (tester) async { final seed = DateTime.now().millisecondsSinceEpoch; final authorName = 'publive$seed'; final readerName = 'pubread$seed'; const password = 'Live1234!publish'; final marker = '真链路发布 $seed'; await register(authorName, password, seed); await register(readerName, password, seed + 1); await tester.pumpWidget( App( sessionManager: SessionManager(store: _InMemoryTokenStore()), mediaUploaderFactory: (repository, analytics) => MediaUploader( repository: repository, picker: _DesktopPicker(), compressor: _PassthroughCompressor(), analytics: analytics, ), ), ); // ---- A 登录并发布(正文 + 一张真上传的图)---- await login(tester, authorName, password); await pumpUntil(tester, find.text('创作')); await tester.tap(find.text('创作')); await pumpUntil(tester, find.text('发布动态')); await tester.tap(find.text('发布动态')); await pumpUntil(tester, find.widgetWithText(TextButton, '存草稿')); await tester.enterText(find.byType(TextField).first, marker); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.byIcon(Icons.add_photo_alternate_outlined)); // 上传全程(createUpload → 预签名 PUT → confirm)走真实链路。 await pumpUntil(tester, find.byType(Image)); final publish = find.widgetWithText(FilledButton, '发布'); for (var i = 0; i < 120; i++) { await tester.pump(const Duration(milliseconds: 250)); if (tester.widget(publish).onPressed != null) break; } expect( tester.widget(publish).onPressed, isNotNull, reason: '图片未在 30s 内 ready,发布钮仍禁用', ); await tester.tap(publish); // ---- 回首页 Feed:新帖置顶可见 ---- await pumpUntil(tester, find.byType(PostCard)); await pumpUntil(tester, find.textContaining(marker)); final cards = tester.widgetList(find.byType(PostCard)).toList(); expect(cards.first.card.contentPreview, contains(marker)); expect(cards.first.card.mediaCount, 1); // ---- 第二客户端(全新 App 实例 + 全新会话):B 登录后同样看到该帖 ---- // 换 key 强制重建整棵树:新的 SessionManager / Controller / HTTP 客户端, // 等价于另一台客户端首次登录(不是同一实例内的账号切换)。 await tester.pumpWidget( App( key: const ValueKey('client-b'), sessionManager: SessionManager(store: _InMemoryTokenStore()), ), ); await login(tester, readerName, password); await pumpUntil(tester, find.byType(PostCard)); await pumpUntil(tester, find.textContaining(marker)); final readerCards = tester .widgetList(find.byType(PostCard)) .toList(); expect(readerCards.first.card.contentPreview, contains(marker)); }, skip: !enabled); }