test(mobile): integration suite against real backend on mumu
P3 Task 11 (WRITE & COMPILE phase; device run pending MuMu availability). - integration_test/p3_flow_test.dart: login -> album (zh+en) -> category -> invite -> enqueue 3 real JPEGs -> queue completed -> server history (sourceDeviceId) -> ov public API sees 3 -> hide via API + UI sync -> DIO fault-gate offline (waitingNetwork -> retry -> 4 completed) -> password change (session kept) -> relogin -> in-app locale smoke. - integration_test/p3_background_test.dart: IT_PHASE=prepare/verify spec for HOME-background continuation and force-stop kill-restart recovery (2 x ~14MB noise JPEGs give a deterministic mid-flight window). - helpers: dart-define config, fault-injecting DIO adapter, app harness (picker/notification/locale provider overrides), UI step library with live-binding polling (no pumpAndSettle). - scripts/run-mobile-it.sh: device detect (exit 2), backend ensure (compose + api/ov/worker/sync, exit 3), idempotent per-run user seed (no DB reset), APK build/install + adb push of testdata, phase 1 / --background phase 2 orchestration with server-side confirmed-count truth via curl. - services/api seed:it script (create-if-absent staff accounts). - debug-only network security config: cleartext HTTP permitted to 10.0.2.2/127.0.0.1/localhost only (release unaffected). - testdata: 3 x ~120KB + 2 x ~14MB generated JPEGs. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
to allow setting breakpoints, to run hot reload, etc. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- P3 Task 11:debug 联调允许到宿主回环(10.0.2.2)的明文 HTTP——
|
||||
本地 cn-api(8080)/MinIO(9000)/ov(8081) 均无 TLS。范围收紧见
|
||||
res/xml/network_security_config.xml(仅回环域,release 不合并)。 -->
|
||||
<application
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- P3 Task 11:debug 构建专用网络策略。
|
||||
targetSdk>=28 默认禁明文 HTTP,而本地联调后端/MinIO 观众端全部是
|
||||
http://10.0.2.2(模拟器宿主回环)——release 不受影响(本文件只在
|
||||
src/debug 变体参与合并)。仅放行宿主回环与本机回环,不放开全域。 -->
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,133 @@
|
||||
/// 集成测试 App 装配(P3 Task 11)。
|
||||
///
|
||||
/// 与生产 `lib/main.dart` 的差异【全部为有意注入点,登记为计划偏差】:
|
||||
/// - **选图替身**:`multiImagePickerProvider`/`singleImagePickerProvider`
|
||||
/// 返回 ADB 推送到设备的小图路径——image_picker 的系统相册 UI 无法在
|
||||
/// instrumentation 下自动驱动(连拍经相机 UI 同理,统一走多选路径,
|
||||
/// 入队后的 准备→队列→引擎→原生桥接→真实后端 链路完全真实);
|
||||
/// - **通知权限 no-op**:避免首个入队动作弹出系统权限对话框卡住自动流
|
||||
/// (脚本端另做 `pm grant` 双保险);
|
||||
/// - **语言固定 zh**:MuMu 系统语言不确定,固定后文案查找器才稳定
|
||||
/// (App 内切换的语义已由 Task 10 单测覆盖,本套件另做一次切换冒烟);
|
||||
/// - **故障闸门**:ApiClient 的 dio adapter 包 [FaultGate](断网场景)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_live/app/app.dart';
|
||||
import 'package:photo_live/app/locale_controller.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/config.dart';
|
||||
import 'package:photo_live/core/notification_permission.dart';
|
||||
import 'package:photo_live/features/uploads/enqueue.dart';
|
||||
|
||||
import 'fault_gate.dart';
|
||||
import 'it_config.dart';
|
||||
|
||||
/// 选图替身:持有「下一次相册动作返回的路径」,消费后清空(未暂存时
|
||||
/// 返回空=模拟用户取消,让调用方走取消分支而不是挂起)。
|
||||
class ItImagePicker {
|
||||
List<String> _staged = const <String>[];
|
||||
|
||||
/// 暂存下一批返回路径。
|
||||
void stage(List<String> paths) => _staged = paths;
|
||||
|
||||
List<String> consume() {
|
||||
final paths = _staged;
|
||||
_staged = const <String>[];
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试装配产物。
|
||||
class ItAppHarness {
|
||||
ItAppHarness._(this.container, this.gate, this.picker);
|
||||
|
||||
/// App 根容器(持有全部覆写;测试经它读 taskStore/apiClient 等)。
|
||||
final ProviderContainer container;
|
||||
|
||||
/// 断网故障闸门。
|
||||
final FaultGate gate;
|
||||
|
||||
/// 选图替身。
|
||||
final ItImagePicker picker;
|
||||
|
||||
/// 装配 App(不 runApp——由 testWidgets 内 pumpWidget 注入
|
||||
/// UncontrolledProviderScope)。
|
||||
static ItAppHarness create() {
|
||||
ItConfig.assertValid();
|
||||
final gate = FaultGate();
|
||||
final picker = ItImagePicker();
|
||||
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: ApiConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
sendTimeout: const Duration(seconds: 60),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
),
|
||||
);
|
||||
dio.httpClientAdapter = FaultInjectingHttpClientAdapter(
|
||||
gate: gate,
|
||||
inner: dio.httpClientAdapter,
|
||||
);
|
||||
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
localeSettingsProvider.overrideWith(
|
||||
() => LocaleSettings(initial: AppLocaleChoice.zh),
|
||||
),
|
||||
apiClientProvider.overrideWith((ref) {
|
||||
final client = ApiClient(
|
||||
sessionStore: ref.watch(sessionStoreProvider),
|
||||
dio: dio,
|
||||
);
|
||||
ref.onDispose(() => client.close());
|
||||
return client;
|
||||
}),
|
||||
multiImagePickerProvider.overrideWithValue(() async => picker.consume()),
|
||||
singleImagePickerProvider.overrideWithValue(() async {
|
||||
final paths = picker.consume();
|
||||
return paths.isEmpty ? null : paths.first;
|
||||
}),
|
||||
notificationPermissionProvider.overrideWithValue(
|
||||
NotificationPermission(isAndroid: false),
|
||||
),
|
||||
],
|
||||
);
|
||||
return ItAppHarness._(container, gate, picker);
|
||||
}
|
||||
|
||||
/// 注入根组件(每个 testWidgets 开头调用)。
|
||||
Widget appWidget() =>
|
||||
UncontrolledProviderScope(container: container, child: const PhotoLiveApp());
|
||||
}
|
||||
|
||||
/// 解析设备端可读的测试图片目录。
|
||||
///
|
||||
/// 优先 App 自有 files 目录(脚本经 `run-as` 拷入,无 SELinux/作用域存储
|
||||
/// 风险),回退 `/data/local/tmp`(adb push 的直读路径,多数模拟器可用)。
|
||||
/// 返回含全部 `it_*.jpg` 的目录路径;两个候选都为空时抛错(提示先跑脚本)。
|
||||
Future<String> resolveImageDir(String supportDirPath) async {
|
||||
final candidates = <String>[
|
||||
p.join(supportDirPath, 'photolive-it'),
|
||||
ItConfig.sourceDir,
|
||||
];
|
||||
for (final dir in candidates) {
|
||||
final list = Directory(dir)
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.map((f) => p.basename(f.path))
|
||||
.where((name) => name.startsWith('it_') && name.endsWith('.jpg'))
|
||||
.toList();
|
||||
if (list.isNotEmpty) return dir;
|
||||
}
|
||||
throw StateError(
|
||||
'设备上找不到测试图片(已尝试 ${candidates.join('、')})——'
|
||||
'请先运行 scripts/run-mobile-it.sh(负责 adb push + run-as 拷贝)',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/// 断网场景注入(P3 Task 11,计划偏差登记项)。
|
||||
///
|
||||
/// 模拟器上断真实网络不便(后端就在宿主 10.0.2.2,断网=断后端不可分区
|
||||
/// 观察),按计划改为 **DIO 层故障注入**:包装 App ApiClient 的
|
||||
/// HttpClientAdapter,闸门关闭期间所有 cn-api 请求立即以 connectionError
|
||||
/// 失败——引擎 initUpload/completeUpload 走该 dio,按 NETWORK_ERROR 分类
|
||||
/// 进入 waitingNetwork 退避,恢复后重试成功,即覆盖「断网→等待网络→
|
||||
/// 恢复→续传」语义。
|
||||
///
|
||||
/// 边界(有意,登记为偏差):presign PUT 在 Android 走原生 WorkManager
|
||||
/// 桥接(不经 dio),本注入不覆盖该腿;waitingNetwork 判定由 initUpload
|
||||
/// 失败驱动,足以驱动状态机目标分支。
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 故障闸门:`offline=true` 期间所有经该 adapter 的请求立即失败。
|
||||
class FaultGate {
|
||||
bool offline = false;
|
||||
}
|
||||
|
||||
/// 包装任意 [HttpClientAdapter] 的故障注入实现。
|
||||
class FaultInjectingHttpClientAdapter implements HttpClientAdapter {
|
||||
FaultInjectingHttpClientAdapter({required this.gate, required this.inner});
|
||||
|
||||
final FaultGate gate;
|
||||
final HttpClientAdapter inner;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
if (gate.offline) {
|
||||
// 与真实断网同型(connectionError → AppException NETWORK_ERROR),
|
||||
// 立即抛出避免 10s 连接超时拖慢场景。
|
||||
throw DioException.connectionError(
|
||||
requestOptions: options,
|
||||
reason: 'it fault gate: offline',
|
||||
);
|
||||
}
|
||||
return inner.fetch(options, requestStream, cancelFuture);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) => inner.close(force: force);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/// P3 Task 11 集成测试配置(dart-define 注入)。
|
||||
///
|
||||
/// 所有值经 `--dart-define KEY=value` 由 `scripts/run-mobile-it.sh` 传入:
|
||||
/// 账号由脚本端 `seed-it-users` 预置(每次运行全新账号,避免污染共享
|
||||
/// dev 库——集成测试【不做 DB reset】,靠账号 + 相册名时间戳隔离)。
|
||||
library;
|
||||
|
||||
/// 一次集成测试运行的全部外部输入。
|
||||
class ItConfig {
|
||||
const ItConfig._();
|
||||
|
||||
/// 主账号(本次运行专属,脚本经 seed-it-users 新建)。
|
||||
static const String mainAccount = String.fromEnvironment('IT_MAIN_ACCOUNT');
|
||||
|
||||
/// 主账号密码。
|
||||
static const String mainPassword = String.fromEnvironment(
|
||||
'IT_MAIN_PASSWORD',
|
||||
);
|
||||
|
||||
/// 受邀协作者账号(脚本预建;密码不需要——App 侧仅邀请不登录它)。
|
||||
static const String staffAccount = String.fromEnvironment('IT_STAFF_ACCOUNT');
|
||||
|
||||
/// ov 公开端点(viewer 侧 API 检查用;默认模拟器宿主回环 8081)。
|
||||
static const String ovBase = String.fromEnvironment(
|
||||
'OV_BASE',
|
||||
defaultValue: 'http://10.0.2.2:8081',
|
||||
);
|
||||
|
||||
/// ADB 推送的小图目录(设备端绝对路径;见 run-mobile-it.sh 的 push 步骤)。
|
||||
static const String sourceDir = String.fromEnvironment(
|
||||
'IT_SOURCE_DIR',
|
||||
defaultValue: '/data/local/tmp/photolive-it',
|
||||
);
|
||||
|
||||
/// 大图文件名(后台/杀进程场景用,同一目录下)。
|
||||
static const String largeFileA = 'it_large_a.jpg';
|
||||
static const String largeFileB = 'it_large_b.jpg';
|
||||
|
||||
/// 本次运行戳(脚本生成;相册名/账号后缀,保证唯一)。
|
||||
static const String runStamp = String.fromEnvironment('IT_RUN_STAMP');
|
||||
|
||||
/// 修改密码场景的新密码(每轮不同:旧密码 + 固定后缀,≥8 位)。
|
||||
static String get rotatedPassword => '$mainPassword-rot8';
|
||||
|
||||
/// 断言入参齐备(缺 dart-define 时给出可读失败而非空账号静默跑挂)。
|
||||
static void assertValid() {
|
||||
final missing = <String>[
|
||||
if (mainAccount.isEmpty) 'IT_MAIN_ACCOUNT',
|
||||
if (mainPassword.isEmpty) 'IT_MAIN_PASSWORD',
|
||||
if (staffAccount.isEmpty) 'IT_STAFF_ACCOUNT',
|
||||
if (runStamp.isEmpty) 'IT_RUN_STAMP',
|
||||
];
|
||||
if (missing.isNotEmpty) {
|
||||
throw StateError(
|
||||
'缺少 dart-define:${missing.join(', ')}(请经 scripts/run-mobile-it.sh '
|
||||
'运行,脚本负责生成账号与戳)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次运行的相册标题(zh)。
|
||||
static String get albumTitleZh => '集成测试相册 $runStamp';
|
||||
|
||||
/// 本次运行的相册标题(en)。
|
||||
static String get albumTitleEn => 'IT Album $runStamp';
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/// 集成测试共用 UI 步骤与轮询助手(P3 Task 11)。
|
||||
///
|
||||
/// 文案查找器固定中文(App 装配固定 zh,见 app_boot.dart)。
|
||||
///
|
||||
/// 轮询模式说明:IntegrationTestWidgetsFlutterBinding 是 live binding——
|
||||
/// 真实时间流逝(引擎 15s 周期 drain、退避 timer、SnackBar 动画都在真
|
||||
/// 时间轴上),因此等待 = `Future.delayed` 真等 + 定期 `tester.pump()`
|
||||
/// 让帧重建。**刻意不用 pumpAndSettle**:队列页的进度条/加载指示器是不
|
||||
/// 定动画,pumpAndSettle 会等到自身超时。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/features/photos/models.dart';
|
||||
import 'package:photo_live/features/uploads/providers.dart';
|
||||
import 'package:photo_live/features/uploads/task_state.dart';
|
||||
|
||||
import 'app_boot.dart';
|
||||
import 'it_config.dart';
|
||||
|
||||
// ── 通用等待 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// 轮询直到 [finder] 可见;超时抛 Fail(附 [because] 上下文)。
|
||||
Future<void> pumpUntil(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
String because = 'finder 出现',
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await tester.pump();
|
||||
if (finder.evaluate().isNotEmpty) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
await tester.pump();
|
||||
if (finder.evaluate().isNotEmpty) return;
|
||||
fail('等待超时($timeout)未见到「$because」:$finder');
|
||||
}
|
||||
|
||||
/// 轮询直到谓词为真(store/网络等非 UI 条件)。
|
||||
Future<T> pollUntil<T>(
|
||||
WidgetTester tester,
|
||||
Future<T> Function() probe,
|
||||
bool Function(T) done, {
|
||||
required Duration timeout,
|
||||
String because = '条件满足',
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
T latest = await probe();
|
||||
while (!done(latest) && DateTime.now().isBefore(deadline)) {
|
||||
await tester.pump();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
latest = await probe();
|
||||
}
|
||||
if (!done(latest)) {
|
||||
fail('等待超时($timeout)「$because」未达成,最后状态:$latest');
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/// Material 日期/时间选择器的确认按钮(zh=确定;回退 OK 防本地化差异)。
|
||||
Future<void> tapDialogOk(WidgetTester tester) async {
|
||||
final ok = find
|
||||
.text('确定')
|
||||
.last
|
||||
.evaluate()
|
||||
.isNotEmpty
|
||||
? find.text('确定').last
|
||||
: find.text('OK').last;
|
||||
await tester.tap(ok, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// ── 队列状态探针(drift 任务库直读——UI 徽章断言的权威旁证)──────────────
|
||||
|
||||
/// 当前账号任务的状态计数。
|
||||
Future<Map<UploadTaskState, int>> queueSnapshot(
|
||||
ItAppHarness harness,
|
||||
String account,
|
||||
) async {
|
||||
final store = harness.container.read(taskStoreProvider);
|
||||
final tasks = await store.listByAccount(account);
|
||||
final counts = <UploadTaskState, int>{};
|
||||
for (final task in tasks) {
|
||||
counts[task.state] = (counts[task.state] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// 等待账号下任务数达到 [total] 且全部 completed。
|
||||
Future<void> waitAllCompleted(
|
||||
WidgetTester tester,
|
||||
ItAppHarness harness,
|
||||
String account,
|
||||
int total,
|
||||
) {
|
||||
return pollUntil<Map<UploadTaskState, int>>(
|
||||
tester,
|
||||
() => queueSnapshot(harness, account),
|
||||
(counts) =>
|
||||
(counts[UploadTaskState.completed] ?? 0) >= total &&
|
||||
counts.length == 1,
|
||||
timeout: const Duration(minutes: 3),
|
||||
because: '$total 个任务全部 completed',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 登录 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 登录表单填写提交并等待进入首页(FAB「新建相册」可见)。
|
||||
Future<void> loginViaUi(
|
||||
WidgetTester tester,
|
||||
String account,
|
||||
String password,
|
||||
) async {
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.widgetWithText(TextFormField, '账号'),
|
||||
because: '登录表单',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '账号'),
|
||||
account,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '密码'),
|
||||
password,
|
||||
);
|
||||
await tester.tap(find.text('登录'));
|
||||
// 相册 tab 的 FAB 是首页就绪标志。
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('新建相册'),
|
||||
timeout: const Duration(seconds: 30),
|
||||
because: '登录成功进入首页',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 相册创建(含 Material 日期+时间选择器驱动)────────────────────────────
|
||||
|
||||
/// 新建双语相册:zh 标题 → English 标题 → 开始时间(选择器默认值确认)→
|
||||
/// 创建 → 返回列表见到新卡片。
|
||||
Future<void> createAlbumViaUi(
|
||||
WidgetTester tester,
|
||||
String zhTitle,
|
||||
String enTitle,
|
||||
) async {
|
||||
await tester.tap(find.text('新建相册'));
|
||||
await pumpUntil(tester, find.text('创建'), because: '相册表单');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '标题 *'),
|
||||
zhTitle,
|
||||
);
|
||||
await tester.tap(find.text('English')); // 内容 tab 切英文条目(非界面语言)
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '标题'),
|
||||
enTitle,
|
||||
);
|
||||
// 开始时间(必填):占位文本「请选择开始时间」即入口。
|
||||
await tester.tap(find.text('请选择开始时间'));
|
||||
await pumpUntil(tester, find.text('确定'), because: '日期选择器');
|
||||
await tapDialogOk(tester); // 日期:默认今天
|
||||
await pumpUntil(tester, find.text('确定'), because: '时间选择器');
|
||||
await tapDialogOk(tester); // 时间:默认当前时刻
|
||||
await tester.pump();
|
||||
await tester.ensureVisible(find.text('创建'));
|
||||
await tester.tap(find.text('创建').last);
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text(zhTitle),
|
||||
timeout: const Duration(seconds: 30),
|
||||
because: '相册创建成功回到列表',
|
||||
);
|
||||
}
|
||||
|
||||
/// 打开相册详情(点击卡片标题)。
|
||||
Future<void> openAlbumDetail(WidgetTester tester, String zhTitle) async {
|
||||
await tester.tap(find.text(zhTitle));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('成员管理'),
|
||||
because: '相册详情页动作行',
|
||||
);
|
||||
}
|
||||
|
||||
/// 新增分类(详情页对话框)。
|
||||
Future<void> addCategoryViaUi(
|
||||
WidgetTester tester,
|
||||
String zhName,
|
||||
String enName,
|
||||
) async {
|
||||
await tester.ensureVisible(find.byTooltip('新增分类'));
|
||||
await tester.tap(find.byTooltip('新增分类'));
|
||||
await pumpUntil(tester, find.text('中文名称'), because: '分类对话框');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '中文名称'),
|
||||
zhName,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '英文名称'),
|
||||
enName,
|
||||
);
|
||||
await tester.tap(find.text('保存').last);
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('$zhName · $enName'),
|
||||
because: '分类列表出现新分类',
|
||||
);
|
||||
}
|
||||
|
||||
/// 邀请成员:搜索账号前缀 → 点「邀请」→ 成员列表出现(协作者)。
|
||||
Future<void> inviteMemberViaUi(WidgetTester tester, String account) async {
|
||||
await tester.tap(find.text('成员管理'));
|
||||
await pumpUntil(tester, find.byType(TextField), because: '成员管理页');
|
||||
await tester.enterText(find.byType(TextField), account);
|
||||
// 防抖 300ms + 搜索请求。
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
await tester.pump();
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('邀请'),
|
||||
because: '搜索结果出现邀请按钮',
|
||||
);
|
||||
await tester.tap(find.text('邀请'));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text(account),
|
||||
timeout: const Duration(seconds: 30),
|
||||
because: '成员列表出现受邀账号',
|
||||
);
|
||||
await pumpUntil(tester, find.text('协作者'), because: '协作者角色标签');
|
||||
await tester.pageBack();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// 详情页 FAB「上传」→「从相册选择」→ 选图替身返回 [paths] → 等待
|
||||
/// 入队提示。调用前须已在相册详情页。
|
||||
Future<void> enqueueViaGallery(
|
||||
WidgetTester tester,
|
||||
ItAppHarness harness,
|
||||
List<String> paths,
|
||||
int expectedCount,
|
||||
) async {
|
||||
harness.picker.stage(paths);
|
||||
// 详情页 InfoCard 的「上传」状态标签与 FAB 文案同字(tagUpload ==
|
||||
// uploadAction ==「上传」),必须收窄到 FAB 内的文本。
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(FloatingActionButton),
|
||||
matching: find.text('上传'),
|
||||
),
|
||||
);
|
||||
await pumpUntil(tester, find.text('从相册选择'), because: '上传底部菜单');
|
||||
await tester.tap(find.text('从相册选择'));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('已加入 $expectedCount 张上传队列'),
|
||||
because: '入队提示',
|
||||
);
|
||||
}
|
||||
|
||||
// ── 照片管理 / 服务端历史 ──────────────────────────────────────────────────
|
||||
|
||||
/// 打开照片管理页并等待首屏行(含 [minCount] 个「已发布」)。
|
||||
Future<void> openPhotosPageAndWait(
|
||||
WidgetTester tester,
|
||||
int minCount,
|
||||
) async {
|
||||
await tester.ensureVisible(find.text('照片管理'));
|
||||
await tester.tap(find.text('照片管理'));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('已发布'),
|
||||
timeout: const Duration(seconds: 60),
|
||||
because: '照片管理列表出现已发布照片',
|
||||
);
|
||||
final published = find.text('已发布').evaluate().length;
|
||||
if (published < minCount) {
|
||||
fail('照片管理页已发布数量不足:$published < $minCount');
|
||||
}
|
||||
}
|
||||
|
||||
// ── ov 观众侧 API 检查(viewer 腿,替代网页断言——登记偏差)──────────────
|
||||
|
||||
/// 轮询 ov 公开相册照片数(经真实 cn→worker→outbox→sync→ov 管线)达到
|
||||
/// [expected] 张。dev:sync 推送周期 2s + worker 处理,正常 <30s,给足 2min。
|
||||
Future<int> waitOvPublicPhotoCount(String publicToken, int expected) async {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: ItConfig.ovBase,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final deadline = DateTime.now().add(const Duration(minutes: 2));
|
||||
var last = -1;
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
try {
|
||||
final resp = await dio.get<Map<String, dynamic>>(
|
||||
'/v1/public/albums/$publicToken/photos',
|
||||
queryParameters: {'limit': 50},
|
||||
);
|
||||
final items = (resp.data?['items'] as List<dynamic>? ?? const <dynamic>[]);
|
||||
last = items.length;
|
||||
if (last >= expected) return last;
|
||||
} on DioException {
|
||||
// 相册/照片尚未同步到 ov:继续轮询。
|
||||
}
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
fail(
|
||||
'ov 公开相册照片数未达到 $expected(最后 $last)——'
|
||||
'检查 dev:worker / dev:sync 是否在运行',
|
||||
);
|
||||
} finally {
|
||||
dio.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏一张照片(管理动作;App 照片页为 Task 4 占位无操作按钮——登记
|
||||
/// 偏差:经 App 同一 ApiClient 走真实契约端点,随后回 UI 断言状态同步)。
|
||||
Future<void> hideFirstPhotoViaApi(
|
||||
ItAppHarness harness,
|
||||
String albumId,
|
||||
) async {
|
||||
final api = harness.container.read(apiClientProvider);
|
||||
final page = await api.listAlbumPhotos(albumId, limit: 20);
|
||||
final published = page.items
|
||||
.where((photo) => photo.visibility == PhotoVisibility.published)
|
||||
.toList();
|
||||
if (published.isEmpty) {
|
||||
fail('相册 $albumId 无已发布照片可隐藏');
|
||||
}
|
||||
await api.updatePhotoVisibility(
|
||||
published.first.photoId,
|
||||
PhotoVisibility.hidden,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 测试图片定位 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// 解析设备端测试图片并返回小图绝对路径列表(it_a/b/c... 顺序稳定)。
|
||||
Future<List<String>> resolveSmallImages() async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
final dir = await resolveImageDir(support.path);
|
||||
final list = Directory(dir)
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.map((file) => file.path)
|
||||
.where((path) {
|
||||
final name = p.basename(path);
|
||||
return name.startsWith('it_') &&
|
||||
name.endsWith('.jpg') &&
|
||||
!name.startsWith('it_large');
|
||||
})
|
||||
.toList()
|
||||
..sort();
|
||||
if (list.length < 3) {
|
||||
fail('测试图片不足 3 张($dir 下仅 ${list.length}):$list');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/// P3 Task 11 集成测试 · 后台延续与杀进程恢复(A04/A05,模拟器级)。
|
||||
///
|
||||
/// 由 `scripts/run-mobile-it.sh --background` 编排,两阶段经
|
||||
/// `--dart-define IT_PHASE=` 区分(App 无法自己 HOME/杀自己——设备动作
|
||||
/// 由脚本在见到 MARKER 后执行,属计划明示的 adb 编排场景):
|
||||
///
|
||||
/// - **prepare**:登录(本次运行专属账号)→ API 建相册 → 直接经准备器
|
||||
/// 入队 2 张大图(~15MB,保证传输有可被中断的时间窗)→ 等任务进入
|
||||
/// uploading → 打 `MARKER:UPLOADING`(脚本此刻 HOME 或 force-stop)→
|
||||
/// 就地等待全部 completed(HOME 场景:WorkManager 后台续传完成后本
|
||||
/// 测试自然通过;KILL 场景:进程被杀,本测试非零退出是预期)。
|
||||
/// - **verify**:全新进程登录同账号 → 断言 2 张全部 completed(KILL 场景
|
||||
/// 覆盖 A05:drift 任务库持久 + 崩溃恢复 uploading→waitingNetwork→
|
||||
/// 幂等重传)→ 服务端历史可见。
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/features/albums/models.dart';
|
||||
import 'package:photo_live/features/uploads/providers.dart';
|
||||
import 'package:photo_live/features/uploads/task_state.dart';
|
||||
|
||||
import 'helpers/app_boot.dart';
|
||||
import 'helpers/it_config.dart';
|
||||
import 'helpers/ui_steps.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final phase = const String.fromEnvironment('IT_PHASE', defaultValue: '');
|
||||
if (phase.isEmpty) {
|
||||
fail('缺少 --dart-define IT_PHASE=prepare|verify(经 run-mobile-it.sh 运行)');
|
||||
}
|
||||
final harness = ItAppHarness.create();
|
||||
|
||||
testWidgets(
|
||||
'P3 后台延续/杀进程恢复 [$phase]',
|
||||
(tester) async {
|
||||
final account = ItConfig.mainAccount;
|
||||
|
||||
if (phase == 'prepare') {
|
||||
await tester.pumpWidget(harness.appWidget());
|
||||
await loginViaUi(tester, account, ItConfig.mainPassword);
|
||||
|
||||
// 相册走 API(建相册的 UI 驱动已在主流程覆盖;本场景主题是传输)。
|
||||
final api = harness.container.read(apiClientProvider);
|
||||
final album = await api.createAlbum(
|
||||
AlbumCreateRequest(
|
||||
translations: [
|
||||
AlbumTranslation(
|
||||
locale: 'zh-CN',
|
||||
title: '后台测试相册 ${ItConfig.runStamp}',
|
||||
),
|
||||
AlbumTranslation(
|
||||
locale: 'en',
|
||||
title: 'BG Album ${ItConfig.runStamp}',
|
||||
),
|
||||
],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
debugPrint('[IT] 后台场景相册:${album.id}');
|
||||
|
||||
// 大图经准备器直接入队(选图 UI 已在主流程覆盖)。
|
||||
final support = await getApplicationSupportDirectory();
|
||||
final dir = await resolveImageDir(support.path);
|
||||
final largeFiles = [ItConfig.largeFileA, ItConfig.largeFileB]
|
||||
.map((name) => p.join(dir, name))
|
||||
.toList();
|
||||
for (final file in largeFiles) {
|
||||
final task = await harness
|
||||
.container
|
||||
.read(uploadPreparerProvider)
|
||||
.prepare(
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
sourcePath: file,
|
||||
);
|
||||
expect(task.state, UploadTaskState.queued, reason: '入队 $file');
|
||||
}
|
||||
debugPrint('[IT] 2 张大图已入队:$largeFiles');
|
||||
|
||||
// 等首个任务进入 uploading(引擎已领取、原生桥接已注册)。
|
||||
await pollUntil<Map<UploadTaskState, int>>(
|
||||
tester,
|
||||
() => queueSnapshot(harness, account),
|
||||
(counts) =>
|
||||
(counts[UploadTaskState.uploading] ?? 0) >= 1 ||
|
||||
(counts[UploadTaskState.uploadedConfirming] ?? 0) >= 1 ||
|
||||
(counts[UploadTaskState.completed] ?? 0) >= 1,
|
||||
timeout: const Duration(seconds: 60),
|
||||
because: '任务进入传输中(脚本随后执行 HOME/force-stop)',
|
||||
);
|
||||
// 脚本协调点:轮询本行出现在 flutter test 输出后立即动作。
|
||||
debugPrint('[IT] MARKER:UPLOADING');
|
||||
|
||||
// HOME 场景:后台完成 → 本断言通过;KILL 场景:进程死于此循环
|
||||
// (非零退出是预期,脚本以 || true 容忍并以 verify 阶段收口)。
|
||||
await waitAllCompleted(tester, harness, account, 2);
|
||||
debugPrint('[IT] MARKER:BG_COMPLETED');
|
||||
} else if (phase == 'verify') {
|
||||
await tester.pumpWidget(harness.appWidget());
|
||||
await loginViaUi(tester, account, ItConfig.mainPassword);
|
||||
// 崩溃恢复收敛断言:in-flight 残留态经 waitingNetwork 重传幂等收口。
|
||||
await waitAllCompleted(tester, harness, account, 2);
|
||||
debugPrint('[IT] verify:2 张大图在持久任务库中均为 completed');
|
||||
|
||||
// 服务端侧旁证:跨设备上传历史 2 条已确认记录。
|
||||
final api = harness.container.read(apiClientProvider);
|
||||
final page = await api.listUploads(limit: 20);
|
||||
final confirmed = page.items
|
||||
.where((record) => record.albumId.isNotEmpty)
|
||||
.length;
|
||||
expect(confirmed, greaterThanOrEqualTo(2));
|
||||
debugPrint('[IT] verify:服务端上传历史 $confirmed 条 OK');
|
||||
} else {
|
||||
fail('未知 IT_PHASE:$phase');
|
||||
}
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 10)),
|
||||
);
|
||||
|
||||
tearDownAll(harness.container.dispose);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/// P3 Task 11 集成测试 · 主流程(MuMu + 真实 cn-api/MinIO/worker/sync/ov)。
|
||||
///
|
||||
/// 覆盖(对照计划 Task 11 第一条):
|
||||
/// 登录 → 建相册(zh+en,Material 日期+时间选择器)→ 加分类 → 邀成员 →
|
||||
/// 传 3 图 → 队列全 completed → 服务端历史可见(sourceDeviceId=本设备)→
|
||||
/// viewer 腿(ov 公开相册 API 见 3 张,替代网页断言——登记偏差)→
|
||||
/// 隐藏一张 → App 管理列表状态同步 → 断网(DIO 故障注入)waiting_network →
|
||||
/// 恢复重试 → 修改密码(会话保留)→ 退出重登录 → App 内语言切换冒烟。
|
||||
///
|
||||
/// 运行方式(勿直接 flutter test——账号/图片由脚本预置):
|
||||
/// `bash scripts/run-mobile-it.sh`
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/features/uploads/task_state.dart';
|
||||
|
||||
import 'helpers/app_boot.dart';
|
||||
import 'helpers/it_config.dart';
|
||||
import 'helpers/ui_steps.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final harness = ItAppHarness.create();
|
||||
|
||||
// 单一大步骤串行全流程:会话/队列状态跨腿复用,顺序即语义。
|
||||
testWidgets(
|
||||
'P3 全流程:真实后端 登录→相册→邀请→上传→历史→ov→隐藏→断网→改密→重登录',
|
||||
(tester) async {
|
||||
final account = ItConfig.mainAccount;
|
||||
var images = const <String>[];
|
||||
|
||||
// ── 0. 启动与资源定位 ─────────────────────────────────────────────
|
||||
await tester.pumpWidget(harness.appWidget());
|
||||
images = await resolveSmallImages();
|
||||
debugPrint('[IT] 使用测试图片:$images');
|
||||
|
||||
// ── 1. 登录(本次运行专属账号)────────────────────────────────────
|
||||
await loginViaUi(tester, account, ItConfig.mainPassword);
|
||||
debugPrint('[IT] leg1 登录 OK:$account');
|
||||
|
||||
// ── 2. 建相册(zh+en 双语标题 + 必填开始时间)──────────────────────
|
||||
await createAlbumViaUi(
|
||||
tester,
|
||||
ItConfig.albumTitleZh,
|
||||
ItConfig.albumTitleEn,
|
||||
);
|
||||
debugPrint('[IT] leg2 建相册 OK:${ItConfig.albumTitleZh}');
|
||||
|
||||
// 相册 id / 公开令牌(后续 API 断言与 ov 检查用)。
|
||||
final api = harness.container.read(apiClientProvider);
|
||||
final albumsPage = await api.listAlbums(limit: 50);
|
||||
final album = albumsPage.items.firstWhere(
|
||||
(a) => a.translations.any(
|
||||
(t) => t.locale == 'zh-CN' && t.title == ItConfig.albumTitleZh,
|
||||
),
|
||||
);
|
||||
final albumId = album.id;
|
||||
final publicToken = album.publicToken;
|
||||
debugPrint('[IT] albumId=$albumId publicToken=$publicToken');
|
||||
|
||||
// ── 3. 详情 + 分类 ────────────────────────────────────────────────
|
||||
await openAlbumDetail(tester, ItConfig.albumTitleZh);
|
||||
await addCategoryViaUi(tester, '全程连拍', 'Burst');
|
||||
debugPrint('[IT] leg3 分类 OK');
|
||||
|
||||
// ── 4. 邀请成员(staff 账号由脚本预建)────────────────────────────
|
||||
await inviteMemberViaUi(tester, ItConfig.staffAccount);
|
||||
debugPrint('[IT] leg4 邀请 OK:${ItConfig.staffAccount}');
|
||||
|
||||
// ── 5. 上传 3 图(选图替身 → 真实准备/队列/引擎/原生桥接/后端)────
|
||||
await enqueueViaGallery(tester, harness, images, 3);
|
||||
await waitAllCompleted(tester, harness, account, 3);
|
||||
debugPrint('[IT] leg5 队列 3 张全部 completed');
|
||||
|
||||
// 队列页 UI:3 个「已完成」徽章(IndexedStack 常驻,切页签后断言)。
|
||||
// 详情页是压在首页骨架之上的路由(无底部导航),先退回首页再切页签。
|
||||
await tester.pageBack();
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.cloud_upload_outlined));
|
||||
await tester.pump();
|
||||
expect(find.text('已完成').evaluate().length, greaterThanOrEqualTo(3));
|
||||
|
||||
// ── 6. 服务端上传历史(跨设备记录 + 本设备标记,A19)───────────────
|
||||
await tester.tap(find.byTooltip('查看服务端上传历史'));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.textContaining('本设备'),
|
||||
timeout: const Duration(seconds: 60),
|
||||
because: '服务端历史出现本设备记录',
|
||||
);
|
||||
final ownDeviceRows = find.textContaining('本设备').evaluate().length;
|
||||
expect(ownDeviceRows, greaterThanOrEqualTo(3));
|
||||
debugPrint('[IT] leg6 服务端历史 OK:$ownDeviceRows 条本设备记录');
|
||||
await tester.pageBack();
|
||||
await tester.pump();
|
||||
|
||||
// ── 7. viewer 腿:ov 公开相册 API 可见 3 张(真实同步管线)────────
|
||||
final ovCount = await waitOvPublicPhotoCount(publicToken, 3);
|
||||
expect(ovCount, greaterThanOrEqualTo(3));
|
||||
debugPrint('[IT] leg7 ov 公开相册 OK:$ovCount 张');
|
||||
|
||||
// ── 8. 隐藏一张 → App 管理列表同步「已隐藏」───────────────────────
|
||||
// (App 照片页为 Task 4 占位无操作按钮——经同一 ApiClient 调真实
|
||||
// 契约端点隐藏,再回 UI 断言状态同步;登记偏差。)
|
||||
await hideFirstPhotoViaApi(harness, albumId);
|
||||
await tester.tap(find.byIcon(Icons.photo_library_outlined));
|
||||
await tester.pump();
|
||||
await openAlbumDetail(tester, ItConfig.albumTitleZh);
|
||||
await openPhotosPageAndWait(tester, 2);
|
||||
expect(find.text('已隐藏').evaluate().length, greaterThanOrEqualTo(1));
|
||||
expect(find.text('已发布').evaluate().length, greaterThanOrEqualTo(2));
|
||||
debugPrint('[IT] leg8 隐藏同步 OK');
|
||||
await tester.pageBack(); // 照片管理 → 详情
|
||||
await tester.pump();
|
||||
|
||||
// ── 9. 断网场景(DIO 故障注入):waiting_network → 恢复重试 ────────
|
||||
harness.gate.offline = true;
|
||||
debugPrint('[IT] leg9 故障闸门关闭(模拟断网)');
|
||||
await enqueueViaGallery(tester, harness, [images.first], 1);
|
||||
await pollUntil<Map<UploadTaskState, int>>(
|
||||
tester,
|
||||
() => queueSnapshot(harness, account),
|
||||
(counts) => (counts[UploadTaskState.waitingNetwork] ?? 0) >= 1,
|
||||
timeout: const Duration(seconds: 90),
|
||||
because: '断网下任务进入 waitingNetwork',
|
||||
);
|
||||
// 队列页 UI:「等待网络」徽章 + 行内重试按钮(先退回首页才有底部导航)。
|
||||
await tester.pageBack();
|
||||
await tester.pump();
|
||||
await tester.tap(find.byIcon(Icons.cloud_upload_outlined));
|
||||
await tester.pump();
|
||||
expect(find.text('等待网络').evaluate().length, greaterThanOrEqualTo(1));
|
||||
debugPrint('[IT] leg9 waitingNetwork 断言 OK(UI+store)');
|
||||
|
||||
harness.gate.offline = false;
|
||||
final retryButton = find.byTooltip('重试');
|
||||
await tester.ensureVisible(retryButton);
|
||||
await tester.tap(retryButton);
|
||||
await waitAllCompleted(tester, harness, account, 4);
|
||||
debugPrint('[IT] leg9 网络恢复重试 OK:4 张全部 completed');
|
||||
|
||||
// ── 10. 修改密码(服务端保留当前会话)→ 会话仍有效 ────────────────
|
||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('修改密码')); // 设置页入口(此时唯一)
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.widgetWithText(TextFormField, '当前密码'),
|
||||
because: '修改密码表单',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '当前密码'),
|
||||
ItConfig.mainPassword,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '新密码'),
|
||||
ItConfig.rotatedPassword,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, '确认新密码'),
|
||||
ItConfig.rotatedPassword,
|
||||
);
|
||||
await tester.tap(find.text('修改密码').last); // 表单提交按钮
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('密码已修改'),
|
||||
because: '修改密码成功提示',
|
||||
);
|
||||
await tester.pageBack(); // 回设置页
|
||||
await tester.pump();
|
||||
// 会话保留断言:当前令牌仍可调需认证端点(refresh 204 语义)。
|
||||
await harness.container.read(apiClientProvider).refresh();
|
||||
debugPrint('[IT] leg10 修改密码 OK(会话保留,refresh 成功)');
|
||||
|
||||
// ── 11. 退出登录 → 新密码重登录 ───────────────────────────────────
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('退出登录'),
|
||||
because: '设置页退出入口',
|
||||
);
|
||||
await tester.tap(find.text('退出登录'));
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.widgetWithText(TextFormField, '账号'),
|
||||
because: '回到登录页',
|
||||
);
|
||||
await loginViaUi(tester, account, ItConfig.rotatedPassword);
|
||||
debugPrint('[IT] leg11 新密码重登录 OK');
|
||||
|
||||
// ── 12. App 内语言切换冒烟(zh→en→zh,P3 Task 10 真机级)──────────
|
||||
await tester.tap(find.byIcon(Icons.settings_outlined));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('语言'));
|
||||
await pumpUntil(tester, find.text('跟随系统'), because: '语言选择弹层');
|
||||
await tester.tap(find.text('English').last);
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('Albums'),
|
||||
because: '界面切换为英文',
|
||||
);
|
||||
debugPrint('[IT] leg12 语言切换 en OK');
|
||||
await tester.tap(find.text('Language'));
|
||||
await pumpUntil(tester, find.text('中文'), because: '语言选择弹层(en)');
|
||||
await tester.tap(find.text('中文').last);
|
||||
await pumpUntil(
|
||||
tester,
|
||||
find.text('相册'),
|
||||
because: '界面切回中文',
|
||||
);
|
||||
debugPrint('[IT] leg12 语言切回 zh OK —— 全流程完成');
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 15)),
|
||||
);
|
||||
|
||||
tearDownAll(harness.container.dispose);
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 MiB |
@@ -326,6 +326,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -413,6 +418,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -525,6 +535,11 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -757,6 +772,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.5.3"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.0.6"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -970,6 +993,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1058,6 +1089,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,6 +39,9 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
# 集成测试(P3 Task 11:MuMu + 真实 cn-api/MinIO/ov 全链路)
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
build_runner: ^2.15.1
|
||||
drift_dev: ^2.35.0
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
# P3 Task 11 集成测试编排(MuMu 模拟器 + 真实本地后端全栈)。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/run-mobile-it.sh # 阶段 1:主流程(登录→建相册→
|
||||
# # 邀请→传图→历史→ov→隐藏→断网→
|
||||
# # 改密→重登录→语言切换)
|
||||
# bash scripts/run-mobile-it.sh --background # 阶段 2:后台延续(HOME 后
|
||||
# # WorkManager 续传)+ 杀进程重启
|
||||
# # 恢复(A04/A05 模拟器级)
|
||||
#
|
||||
# 前置:
|
||||
# · MuMu 已启动(脚本【不会】尝试启动 MuMu;被更新弹窗挡住时请先处理)
|
||||
# · Docker Desktop 运行中(infra compose:mysql/redis/minio 双地域)
|
||||
# · 工具链经 scripts/flutter-env.sh 注入(本脚本自行 source)
|
||||
#
|
||||
# 数据隔离约定(集成测试【不做 DB reset】——dev 库共享):
|
||||
# · 每次运行生成全新专属账号(itmain_/itstaff_/itbg1_/itbg2_ + 时间戳),
|
||||
# 由 services/api seed:it 幂等预置;
|
||||
# · 相册标题带同一时间戳后缀;密码修改只作用于本次专属账号。
|
||||
#
|
||||
# 退出码:0=通过;1=断言/命令失败;2=无可用设备;3=后端未能就绪。
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "$REPO_ROOT/scripts/flutter-env.sh"
|
||||
|
||||
MOBILE_DIR="$REPO_ROOT/apps/mobile"
|
||||
APP_ID="com.photolive.photo_live"
|
||||
TESTDATA_DIR="$MOBILE_DIR/integration_test/testdata"
|
||||
LOG_DIR="$MOBILE_DIR/build/it-logs"
|
||||
RUN_STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
API_HOST="http://127.0.0.1:8080"
|
||||
OV_HOST="http://127.0.0.1:8081"
|
||||
API_BASE_DEVICE="http://10.0.2.2:8080" # 模拟器内看到的宿主
|
||||
OV_BASE_DEVICE="http://10.0.2.2:8081"
|
||||
VIEWER_BASE_DEVICE="http://10.0.2.2:5175"
|
||||
ADB="${ANDROID_HOME}/platform-tools/adb"
|
||||
|
||||
log() { echo "[run-mobile-it $RUN_STAMP] $*"; }
|
||||
die() { log "错误:$1"; exit "${2:-1}"; }
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# ── 设备(不启动 MuMu,只探测)──────────────────────────────────────────────
|
||||
connect_device() {
|
||||
for port in 16384 7555 5555 21503; do
|
||||
"$ADB" connect "127.0.0.1:$port" >/dev/null 2>&1 || true
|
||||
done
|
||||
DEVICE_ID="$("$ADB" devices | awk '$2 == "device" {print $1; exit}')"
|
||||
if [[ -z "$DEVICE_ID" ]]; then
|
||||
echo "未发现可用 Android 设备(MuMu 未启动或 adb 未连上)。" >&2
|
||||
echo "请先启动 MuMu 模拟器(adb connect 127.0.0.1:16384),再重跑本脚本。" >&2
|
||||
exit 2
|
||||
fi
|
||||
log "设备:$DEVICE_ID"
|
||||
}
|
||||
|
||||
# ── 后端就绪(compose 基础设施 + 四个 dev 服务)────────────────────────────
|
||||
# api/ov 以 health 端点为准;worker/sync 无 HTTP 端点,用 CIM 查 node 命令行
|
||||
# 防重复拉起(即便重复:BullMQ 任务锁 + 同步 eventId 幂等,双实例安全——
|
||||
# 仓库 sync 设计明示与常驻 dev:sync 并发安全)。
|
||||
node_proc_count() { # $1=命令行通配模式
|
||||
powershell.exe -NoProfile -Command \
|
||||
"(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { \$_.CommandLine -like '*$1*' }).Count" \
|
||||
2>/dev/null | tr -d '\r' || echo 0
|
||||
}
|
||||
|
||||
spawn_service() { # $1=名 $2=pnpm 脚本 $3=日志文件
|
||||
log "后台拉起 $1(日志 $3)"
|
||||
( cd "$REPO_ROOT" && nohup pnpm "$2" >"$3" 2>&1 & )
|
||||
}
|
||||
|
||||
wait_http() { # $1=url $2=超时秒 $3=名称
|
||||
local i=0
|
||||
until curl -sf "$1" >/dev/null 2>&1; do
|
||||
i=$((i + 1))
|
||||
if (( i >= $2 )); then return 1; fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
ensure_backend() {
|
||||
log "检查基础设施容器(mysql/redis/minio 双地域)"
|
||||
( cd "$REPO_ROOT" && docker compose -f infra/docker-compose.yml up -d ) \
|
||||
|| die "docker compose up 失败(Docker Desktop 是否在运行?)"
|
||||
|
||||
if ! wait_http "$API_HOST/v1/health" 5 cn-api; then
|
||||
spawn_service dev:api "$LOG_DIR/dev-api.log"
|
||||
fi
|
||||
if ! wait_http "$OV_HOST/v1/health" 5 ov-api; then
|
||||
spawn_service dev:ov "$LOG_DIR/dev-ov.log"
|
||||
fi
|
||||
if ! wait_http "$API_HOST/v1/health" 90 cn-api; then
|
||||
tail -n 40 "$LOG_DIR/dev-api.log" || true
|
||||
die "cn-api(8080) 未能就绪" 3
|
||||
fi
|
||||
if ! wait_http "$OV_HOST/v1/health" 90 ov-api; then
|
||||
tail -n 40 "$LOG_DIR/dev-ov.log" || true
|
||||
die "ov-api(8081) 未能就绪" 3
|
||||
fi
|
||||
|
||||
if [[ "$(node_proc_count '*worker*dist*main.js*')" == "0" ]]; then
|
||||
spawn_service dev:worker "$LOG_DIR/dev-worker.log"
|
||||
fi
|
||||
if [[ "$(node_proc_count '*sync*dist*main.js*')" == "0" ]]; then
|
||||
spawn_service dev:sync "$LOG_DIR/dev-sync.log"
|
||||
fi
|
||||
# worker/sync 启动缓冲(无探针可等,只保证进程活过冷启动期)。
|
||||
sleep 3
|
||||
log "后端就绪:cn-api(8080) ov-api(8081) worker sync"
|
||||
}
|
||||
|
||||
# ── 账号种子(幂等;dev admin 确保 + 本次专属账号)──────────────────────────
|
||||
seed_users() { # $1=IT_USERS 值(account:password,...)
|
||||
log "种子用户(seed:it,已存在跳过不改写)"
|
||||
( cd "$REPO_ROOT" && pnpm --filter @photo/api-cn seed ) \
|
||||
|| die "seed-admin 失败(.env 的 ADMIN_ACCOUNT/ADMIN_PASSWORD 是否配置?)"
|
||||
( cd "$REPO_ROOT" && IT_USERS="$1" pnpm --filter @photo/api-cn seed:it ) \
|
||||
|| die "seed-it-users 失败"
|
||||
}
|
||||
|
||||
# ── 测试素材上设备 + App 预安装(run-as 需要 debug 包已装)─────────────────
|
||||
push_images_and_prep_app() {
|
||||
log "构建/安装 debug APK(run-as 前置)"
|
||||
( cd "$MOBILE_DIR" && flutter build apk --debug ) || die "debug APK 构建失败"
|
||||
"$ADB" -s "$DEVICE_ID" install -r "$MOBILE_DIR/build/app/outputs/flutter-apk/app-debug.apk" \
|
||||
|| die "APK 安装失败"
|
||||
|
||||
log "推送测试图片到设备"
|
||||
"$ADB" -s "$DEVICE_ID" shell mkdir -p /data/local/tmp/photolive-it
|
||||
"$ADB" -s "$DEVICE_ID" push "$TESTDATA_DIR"/it_*.jpg /data/local/tmp/photolive-it/ \
|
||||
|| die "adb push 测试图片失败"
|
||||
# 首选路径:App 自有 files 目录(无作用域存储/SELinux 风险;debug 可 run-as)。
|
||||
"$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" mkdir -p files/photolive-it || true
|
||||
for f in "$TESTDATA_DIR"/it_*.jpg; do
|
||||
"$ADB" -s "$DEVICE_ID" shell run-as "$APP_ID" cp \
|
||||
"/data/local/tmp/photolive-it/$(basename "$f")" files/photolive-it/ || true
|
||||
done
|
||||
# 上传前台服务通知权限:预授权避免系统权限弹窗打断自动化(33+)。
|
||||
"$ADB" -s "$DEVICE_ID" shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS || true
|
||||
}
|
||||
|
||||
# ── flutter test 包装(tee 日志;$@=额外 dart-define 已拼好的参数)──────────
|
||||
run_test() { # $1=测试文件 $2=日志名 $3...=--dart-define 串
|
||||
local file="$1" logf="$2"; shift 2
|
||||
( cd "$MOBILE_DIR" && flutter test "$file" -d "$DEVICE_ID" "$@" ) \
|
||||
2>&1 | tee "$LOG_DIR/$logf"
|
||||
return "${PIPESTATUS[0]}"
|
||||
}
|
||||
|
||||
# ── 服务端旁证(阶段 2 主判定:后端侧确认上传完成)────────────────────────
|
||||
server_login() { # $1=账号 $2=密码 → stdout token
|
||||
curl -sf -X POST "$API_HOST/v1/auth/password" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"account\":\"$1\",\"password\":\"$2\"}" \
|
||||
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(j.sessionToken||'')})"
|
||||
}
|
||||
|
||||
server_confirmed_count() { # $1=token
|
||||
curl -sf "$API_HOST/v1/uploads?limit=20" -H "Authorization: Bearer $1" \
|
||||
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(String((j.items||[]).filter(i=>i.state==='confirmed').length))})"
|
||||
}
|
||||
|
||||
wait_marker() { # $1=日志 $2=marker $3=超时秒
|
||||
local i=0
|
||||
until grep -q "$2" "$1" 2>/dev/null; do
|
||||
i=$((i + 2))
|
||||
if (( i >= $3 )); then return 1; fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
gen_password() { echo "ItPw-$(openssl rand -hex 6)"; }
|
||||
|
||||
# ═════════════════════════════ 阶段 1:主流程 ═════════════════════════════
|
||||
phase1() {
|
||||
local main="itmain_$RUN_STAMP" staff="itstaff_$RUN_STAMP"
|
||||
local main_pw; main_pw="$(gen_password)"
|
||||
seed_users "$main:$main_pw,$staff:$(gen_password)"
|
||||
|
||||
push_images_and_prep_app
|
||||
|
||||
log "阶段 1 运行主流程套件(登录→相册→邀请→上传→历史→ov→隐藏→断网→改密→重登录)"
|
||||
run_test integration_test/p3_flow_test.dart "phase1-$RUN_STAMP.log" \
|
||||
--dart-define "API_BASE=$API_BASE_DEVICE" \
|
||||
--dart-define "VIEWER_BASE=$VIEWER_BASE_DEVICE" \
|
||||
--dart-define "OV_BASE=$OV_BASE_DEVICE" \
|
||||
--dart-define "IT_MAIN_ACCOUNT=$main" \
|
||||
--dart-define "IT_MAIN_PASSWORD=$main_pw" \
|
||||
--dart-define "IT_STAFF_ACCOUNT=$staff" \
|
||||
--dart-define "IT_RUN_STAMP=$RUN_STAMP" \
|
||||
|| die "阶段 1(主流程)失败——日志 $LOG_DIR/phase1-$RUN_STAMP.log"
|
||||
log "阶段 1 通过 ✔"
|
||||
}
|
||||
|
||||
# ═══════════════ 阶段 2:后台延续 + 杀进程恢复(A04/A05)═══════════════════
|
||||
bg_scenario() { # $1=场景名(bg-home|kill-restart) $2=账号 $3=密码
|
||||
local scenario="$1" account="$2" password="$3"
|
||||
local define_common=(
|
||||
--dart-define "API_BASE=$API_BASE_DEVICE"
|
||||
--dart-define "VIEWER_BASE=$VIEWER_BASE_DEVICE"
|
||||
--dart-define "OV_BASE=$OV_BASE_DEVICE"
|
||||
--dart-define "IT_MAIN_ACCOUNT=$account"
|
||||
--dart-define "IT_MAIN_PASSWORD=$password"
|
||||
--dart-define "IT_RUN_STAMP=$RUN_STAMP"
|
||||
)
|
||||
local prep_log="$LOG_DIR/${scenario}-prepare-$RUN_STAMP.log"
|
||||
|
||||
log "$scenario:prepare 阶段启动(登录→建相册→入队 2 张大图→进入上传中)"
|
||||
( cd "$MOBILE_DIR" && flutter test integration_test/p3_background_test.dart \
|
||||
-d "$DEVICE_ID" "${define_common[@]}" --dart-define IT_PHASE=prepare ) \
|
||||
>"$prep_log" 2>&1 &
|
||||
local prep_pid=$!
|
||||
|
||||
if ! wait_marker "$prep_log" "MARKER:UPLOADING" 600; then
|
||||
tail -n 60 "$prep_log" || true
|
||||
die "$scenario:未等到 MARKER:UPLOADING(含构建安装最长 10 分钟)"
|
||||
fi
|
||||
|
||||
if [[ "$scenario" == "bg-home" ]]; then
|
||||
log "$scenario:HOME 键切后台(上传仍进行中)"
|
||||
"$ADB" -s "$DEVICE_ID" shell input keyevent KEYCODE_HOME
|
||||
local token; token="$(server_login "$account" "$password")"
|
||||
[[ -n "$token" ]] || die "$scenario:服务端登录失败(旁证无法进行)"
|
||||
local i=0 confirmed=0
|
||||
until confirmed="$(server_confirmed_count "$token")" && (( confirmed >= 2 )); do
|
||||
i=$((i + 5)); if (( i >= 300 )); then
|
||||
die "$scenario:后台 5 分钟内未见 2 条 confirmed(最后 $confirmed)"
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
log "$scenario:服务端旁证 OK——后台(WorkManager 路径)2 张 confirmed"
|
||||
# prepare 测试自行收尾(后台 Dart 侧收到完成事件即退出);防冻结:至多等 2 分钟。
|
||||
local w=0
|
||||
while kill -0 "$prep_pid" 2>/dev/null && (( w < 120 )); do sleep 5; w=$((w + 5)); done
|
||||
kill "$prep_pid" 2>/dev/null || true
|
||||
else
|
||||
log "$scenario:force-stop 杀进程(上传中)"
|
||||
"$ADB" -s "$DEVICE_ID" shell am force-stop "$APP_ID"
|
||||
wait "$prep_pid" || log "$scenario:prepare 被杀后 flutter test 非零退出(预期内)"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
log "$scenario:verify 阶段(新进程登录→队列恢复/收口→服务端历史)"
|
||||
run_test integration_test/p3_background_test.dart \
|
||||
"${scenario}-verify-$RUN_STAMP.log" \
|
||||
"${define_common[@]}" --dart-define IT_PHASE=verify \
|
||||
|| die "$scenario:verify 失败——日志 $LOG_DIR/${scenario}-verify-$RUN_STAMP.log"
|
||||
log "$scenario 通过 ✔"
|
||||
}
|
||||
|
||||
phase2() {
|
||||
local bg1="itbg1_$RUN_STAMP" bg2="itbg2_$RUN_STAMP"
|
||||
local pw1 pw2; pw1="$(gen_password)"; pw2="$(gen_password)"
|
||||
seed_users "$bg1:$pw1,$bg2:$pw2"
|
||||
|
||||
push_images_and_prep_app
|
||||
|
||||
bg_scenario bg-home "$bg1" "$pw1"
|
||||
bg_scenario kill-restart "$bg2" "$pw2"
|
||||
log "阶段 2 全部通过 ✔(A04 后台延续 + A05 杀进程恢复,模拟器级)"
|
||||
}
|
||||
|
||||
# ── 入口 ───────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
connect_device
|
||||
ensure_backend
|
||||
if [[ "${1:-}" == "--background" ]]; then
|
||||
phase2
|
||||
elif [[ -z "${1:-}" ]]; then
|
||||
phase1
|
||||
else
|
||||
die "未知参数:$1(可用:--background)"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -9,6 +9,7 @@
|
||||
"start": "tsc -p tsconfig.build.json && node dist/main-cn.js",
|
||||
"start:ov": "tsc -p tsconfig.build.json && node dist/main-ov.js",
|
||||
"seed": "tsc -p tsconfig.build.json && node dist/scripts/seed-admin.js",
|
||||
"seed:it": "tsc -p tsconfig.build.json && node dist/scripts/seed-it-users.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "jest",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// services/api/src/scripts/seed-it-users.ts —— P3 Task 11 集成测试专用
|
||||
// 用户种子脚本(MuMu + 真实后端套件的预置步骤,由 scripts/run-mobile-it.sh
|
||||
// 调用)。运行方式:`IT_USERS="a:pw1,b:pw2" pnpm --filter @photo/api-cn seed:it`
|
||||
// (= tsc 构建后 node dist/scripts/seed-it-users.js)。
|
||||
//
|
||||
// 与共享 dev 库的隔离约定(集成测试【不做 DB reset】,见脚本头注释):
|
||||
// - 每次运行由编排脚本生成【全新唯一账号】(时间戳后缀)传入本脚本;
|
||||
// - 本脚本只做「不存在则建」,存在即跳过且【绝不改写密码/角色】——
|
||||
// 与 seed-admin 同幂等语义,重复调用安全;
|
||||
// - 角色固定 staff:相册创建/邀请查找在 cn 侧仅要求已认证(SessionGuard),
|
||||
// 测试账号无需 admin 角色。
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import { hashPassword } from '../auth/password';
|
||||
|
||||
/** 解析 `account:password,...` 列表(冒号冲突风险可接受:账号不含冒号)。 */
|
||||
export function parseItUsers(raw: string): Array<{ account: string; password: string }> {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((pair) => pair.trim())
|
||||
.filter((pair) => pair !== '')
|
||||
.map((pair) => {
|
||||
const sep = pair.indexOf(':');
|
||||
if (sep <= 0) {
|
||||
throw new Error(`seed-it-users: 非法条目 "${pair}"(应为 account:password)`);
|
||||
}
|
||||
const account = pair.slice(0, sep).trim();
|
||||
const password = pair.slice(sep + 1);
|
||||
if (account === '' || password === '') {
|
||||
throw new Error(`seed-it-users: 空账号/密码(${pair})`);
|
||||
}
|
||||
return { account, password };
|
||||
});
|
||||
}
|
||||
|
||||
/** 种子核心(可测):逐个建号,已存在跳过。返回账号 → 是否新建。 */
|
||||
export async function seedItUsers(
|
||||
prisma: PrismaClient,
|
||||
users: Array<{ account: string; password: string }>,
|
||||
): Promise<Map<string, boolean>> {
|
||||
const result = new Map<string, boolean>();
|
||||
for (const { account, password } of users) {
|
||||
const existing = await prisma.users.findUnique({ where: { account } });
|
||||
if (existing !== null) {
|
||||
result.set(account, false);
|
||||
continue;
|
||||
}
|
||||
await prisma.users.create({
|
||||
data: {
|
||||
account,
|
||||
passwordHash: await hashPassword(password),
|
||||
displayName: account,
|
||||
role: 'staff',
|
||||
credentialStatus: 'active',
|
||||
},
|
||||
});
|
||||
result.set(account, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 定位仓库根 .env(与 seed-admin 相同约定:不覆盖已有进程环境变量)。 */
|
||||
function loadRootEnv(): void {
|
||||
let dir = __dirname;
|
||||
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
throw new Error('seed-it-users: pnpm-workspace.yaml not found');
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
config({ path: join(dir, '.env') });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
loadRootEnv();
|
||||
const raw = process.env.IT_USERS;
|
||||
if (!raw) {
|
||||
throw new Error('seed-it-users: IT_USERS 环境变量缺失(格式 account:password,...)');
|
||||
}
|
||||
const users = parseItUsers(raw);
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const created = await seedItUsers(prisma, users);
|
||||
for (const [account, wasCreated] of created) {
|
||||
console.log(
|
||||
`seed-it-users: ${account} ${wasCreated ? 'created (staff)' : 'exists, skipped'}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main().catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user