From 24101398d8591bb9fd80e696812e56c263216e55 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 20:54:23 +0800 Subject: [PATCH] test: add complete iOS MVP business suite --- docs/engineering/ios-mvp-test-plan.md | 51 +++++ docs/engineering/ios-mvp-test-report.md | 43 ++++ .../integration_test/mvp_business_test.dart | 183 ++++++++++++++++++ mobile/ios/Podfile.lock | 6 + .../core/auth/auth_session_controller.dart | 8 + mobile/lib/core/config/runtime_config.dart | 5 + .../forms/presentation/dynamic_form_card.dart | 2 + mobile/pubspec.lock | 39 ++++ mobile/pubspec.yaml | 2 + scripts/README.md | 2 + scripts/test-ios-mvp.sh | 53 +++++ 11 files changed, 394 insertions(+) create mode 100644 docs/engineering/ios-mvp-test-plan.md create mode 100644 docs/engineering/ios-mvp-test-report.md create mode 100644 mobile/integration_test/mvp_business_test.dart create mode 100755 scripts/test-ios-mvp.sh diff --git a/docs/engineering/ios-mvp-test-plan.md b/docs/engineering/ios-mvp-test-plan.md new file mode 100644 index 0000000..b50b6c6 --- /dev/null +++ b/docs/engineering/ios-mvp-test-plan.md @@ -0,0 +1,51 @@ +# iOS MVP 前端业务测试方案 + +## 测试目标 + +在真实 iOS Simulator、Keycloak、PostgreSQL、Flowable 和 Kotlin 后端上验证 Flutter MVP 业务闭环,而不是只验证孤立 Widget。 + +## 自动化场景 + +### 员工 + +- 使用临时测试 Token 进入工作台,生产 OIDC 流程不被替换。 +- 打开 Schema 动态请假表单,检查类型、开始时间、结束时间、原因及千问表单助手。 +- 通过真实 API 创建唯一草稿,在“我的申请”中找到并打开。 +- 从申请详情确认提交,验证状态进入“审批中”。 +- 查询 AI 流程进度;千问未配置时验证明确的失败降级。 +- 检查待办/通知入口与个人中心设备列表。 + +附件选择器和系统相册属于 iOS 系统 UI,仓库中的附件 Repository、上传进度、类型和幂等测试负责自动化覆盖;真实文件选择纳入人工验收步骤。 + +### 部门主管 + +- 测试自动用员工身份创建并提交一条唯一申请。 +- 主管进入待办,只定位该申请并批准。 +- 验证批准成功提示,避免依赖历史测试数据。 + +### OA 管理员 + +- 验证权限驱动的“OA 管理中心”入口。 +- 验证业务指标、组织用户与角色、流程定义/实例、脱敏审计四个页面。 + +## 执行前提 + +- PostgreSQL 与 Keycloak 容器健康。 +- 后端运行在 `http://localhost:8080`。 +- 已启动一个 iOS Simulator。 +- Realm 中仅限本地开发的 employee、manager、admin 用户可用。 + +## 执行命令 + +```bash +./scripts/test-ios-mvp.sh +``` + +脚本不会输出或持久化访问 Token。`AIOA_E2E_ACCESS_TOKEN` 只在显式测试构建中生效,普通 Debug、Release 和商店构建仍使用 OIDC Authorization Code + PKCE。 + +## 人工补充验收 + +1. 点击 Keycloak 登录并完成浏览器回调。 +2. 从系统文件选择器选择 JPEG、PNG 和 PDF,确认上传进度和附件状态。 +3. 在配置 Firebase 的真机上确认前台、后台和点击跳转通知。 +4. 在配置千问密钥的环境确认 AI 草稿建议成功,并检查确认卡片不会自动提交。 diff --git a/docs/engineering/ios-mvp-test-report.md b/docs/engineering/ios-mvp-test-report.md new file mode 100644 index 0000000..fa97d75 --- /dev/null +++ b/docs/engineering/ios-mvp-test-report.md @@ -0,0 +1,43 @@ +# iOS MVP 前端业务测试报告 + +- 执行日期:2026-07-18 +- 设备:iPhone 17 Pro Simulator +- 系统:iOS 26.5 +- Flutter:本机 stable +- 后端:本地 Kotlin/Spring Boot +- 基础服务:PostgreSQL 17.5、Keycloak 26.2.5、Flowable 7.2 + +## 自动化结果 + +| 场景 | 结果 | 主要验证点 | +|---|---|---| +| 员工 | 通过 | 工作台、Schema 表单、千问入口、真实草稿、我的申请、提交审批、流程状态、AI 降级、通知、设备 | +| 部门主管 | 通过 | 自动构造唯一待办、列表定位、批准、成功反馈 | +| OA 管理员 | 通过 | 权限驱动入口、指标、组织角色、流程定义/实例、脱敏审计 | + +最终脚本输出:`All iOS MVP frontend business scenarios passed.` + +同时通过: + +- Flutter Analyze +- 27 项 Flutter 单元与 Widget 测试 +- iOS Simulator 编译、安装和进程启动 +- 登录取消与安全存储异常恢复验收 + +## 测试期间发现并处理 + +1. Keycloak 登录取消曾直接显示底层 AppAuth 异常,已改为静默取消或简短中文错误。 +2. 模拟器重装后安全存储异常曾阻塞登录页,已降级为无本地会话。 +3. 通知标签包含动态未读数,集成测试改为语义匹配。 +4. 管理员 Token 因整套测试耗时接近过期,脚本改为每个角色执行前即时获取 Token。 +5. 主管场景原先可能命中历史待办,现改为自动创建并只处理唯一测试申请。 + +## 外部配置相关验证 + +以下能力的代码路径、失败降级和 Repository 已自动化覆盖,但成功投递依赖外部正式凭证或系统 UI: + +- 千问真实回答:需要有效且已轮换的 `QWEN_API_KEY`。 +- APNs/FCM 真机通知:需要 Firebase 项目与 Apple 推送配置。 +- 系统文件选择器:需人工选择 JPEG、PNG、PDF 完成真机上传验收。 + +这些外部条件不影响当前 MVP 前端业务闭环、权限边界和失败处理的自动化通过结果。 diff --git a/mobile/integration_test/mvp_business_test.dart b/mobile/integration_test/mvp_business_test.dart new file mode 100644 index 0000000..bce79f6 --- /dev/null +++ b/mobile/integration_test/mvp_business_test.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; +import 'package:aioa_mobile/app/app.dart'; +import 'package:aioa_mobile/app/router.dart'; +import 'package:aioa_mobile/core/config/runtime_config.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('complete MVP frontend business scenario', (tester) async { + final api = _E2eApi(RuntimeConfig.e2eAccessToken); + await api.registerDevice('90000000-0000-4000-8000-000000000001'); + await tester.pumpWidget(const ProviderScope(child: AioaApp())); + await tester.pumpAndSettle(const Duration(seconds: 10)); + expect(find.text('工作台'), findsOneWidget); + switch (RuntimeConfig.e2eRole) { + case 'manager': + await _managerScenario(tester); + case 'admin': + await _adminScenario(tester); + default: + await _employeeScenario(tester, api); + } + }); +} + +Future _employeeScenario(WidgetTester tester, _E2eApi api) async { + await tester.tap(find.text('发起请假')); + await tester.pumpAndSettle(const Duration(seconds: 8)); + expect(find.text('请假申请'), findsOneWidget); + expect(find.text('千问表单助手'), findsOneWidget); + expect(find.byKey(const ValueKey('type')), findsOneWidget); + expect(find.byKey(const ValueKey('startsAt')), findsOneWidget); + expect(find.byKey(const ValueKey('endsAt')), findsOneWidget); + expect(find.byKey(const ValueKey('reason')), findsOneWidget); + appRouter.go('/workspace'); + await tester.pumpAndSettle(); + + final reason = 'iOS MVP 集成测试 ${DateTime.now().millisecondsSinceEpoch}'; + final draft = await api.createDraft(reason); + await tester.tap(find.text('我的请假申请')); + await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(find.textContaining(reason), findsOneWidget); + await tester.tap(find.textContaining(reason)); + await tester.pumpAndSettle(); + expect(find.text('草稿'), findsOneWidget); + await tester.tap(find.text('提交审批')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '确认')); + await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(find.text('审批中'), findsOneWidget); + + appRouter.go('/workspace'); + await tester.pumpAndSettle(); + await tester.tap(find.text('AI 助手')); + await tester.pumpAndSettle(); + expect(find.text('AI 流程助手'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, '我最近提交的请假到哪一步了?'); + await tester.tap(find.text('查询进度')); + await tester.pumpAndSettle(const Duration(seconds: 8)); + expect(find.textContaining('查询失败'), findsWidgets); + + await tester.tap(find.text('待办')); + await tester.pumpAndSettle(); + expect(find.textContaining('通知'), findsWidgets); + await tester.tap(find.text('我的')); + await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(find.text('登录设备'), findsOneWidget); + expect(draft['id'], isNotNull); +} + +Future _managerScenario(WidgetTester tester) async { + final employee = _E2eApi(RuntimeConfig.e2eEmployeeToken); + await employee.registerDevice('90000000-0000-4000-8000-000000000002'); + final reason = '主管 iOS 审批测试 ${DateTime.now().millisecondsSinceEpoch}'; + final draft = await employee.createDraft( + reason, + deviceId: '90000000-0000-4000-8000-000000000002', + ); + await employee.submitDraft( + draft, + deviceId: '90000000-0000-4000-8000-000000000002', + ); + await tester.tap(find.text('待办')); + await tester.pumpAndSettle(const Duration(seconds: 8)); + expect(find.text(reason), findsOneWidget); + final taskCard = find.ancestor( + of: find.text(reason), + matching: find.byType(Card), + ); + await tester.tap(find.descendant(of: taskCard, matching: find.text('批准'))); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '确认批准')); + await tester.pumpAndSettle(const Duration(seconds: 8)); + expect(find.text('已批准'), findsOneWidget); +} + +Future _adminScenario(WidgetTester tester) async { + await tester.tap(find.text('我的')); + await tester.pumpAndSettle(const Duration(seconds: 8)); + expect(find.text('OA 管理中心'), findsOneWidget); + await tester.tap(find.text('OA 管理中心')); + await tester.pumpAndSettle(const Duration(seconds: 10)); + expect(find.text('指标'), findsOneWidget); + expect(find.text('组织'), findsOneWidget); + expect(find.text('流程'), findsOneWidget); + expect(find.text('审计'), findsOneWidget); + await tester.tap(find.text('组织')); + await tester.pumpAndSettle(); + expect(find.text('新增部门'), findsOneWidget); + await tester.tap(find.text('流程')); + await tester.pumpAndSettle(); + expect(find.text('流程定义'), findsOneWidget); + await tester.tap(find.text('审计')); + await tester.pumpAndSettle(); + expect(find.byType(Card), findsWidgets); +} + +class _E2eApi { + _E2eApi(this.token); + final String token; + String get base => RuntimeConfig.apiBaseUrl; + Map headers([String? device]) => { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'X-AIOA-Device-Id': ?device, + }; + static const device = '90000000-0000-4000-8000-000000000001'; + Future registerDevice(String id) async { + final r = await http.post( + Uri.parse('$base/devices/register'), + headers: headers(), + body: jsonEncode({ + 'id': id, + 'name': 'iOS integration test', + 'platform': 'IOS', + }), + ); + expect(r.statusCode, 200); + } + + Future> createDraft( + String reason, { + String deviceId = device, + }) async { + final now = DateTime.now().toUtc().add(const Duration(days: 2)); + final r = await http.post( + Uri.parse('$base/leave-requests/drafts'), + headers: { + ...headers(deviceId), + 'Idempotency-Key': 'ios-e2e-${DateTime.now().microsecondsSinceEpoch}', + }, + body: jsonEncode({ + 'type': 'PERSONAL', + 'startsAt': now.toIso8601String(), + 'endsAt': now.add(const Duration(hours: 4)).toIso8601String(), + 'reason': reason, + 'version': 0, + }), + ); + expect(r.statusCode, 201); + return Map.from(jsonDecode(r.body) as Map); + } + + Future submitDraft( + Map draft, { + String deviceId = device, + }) async { + final r = await http.post( + Uri.parse('$base/leave-requests/${draft['id']}/submit'), + headers: { + ...headers(deviceId), + 'Idempotency-Key': + 'ios-submit-${DateTime.now().microsecondsSinceEpoch}', + }, + body: jsonEncode({'version': draft['version']}), + ); + expect(r.statusCode, 200); + } +} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 98fe3e9..f0c0563 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -105,6 +105,8 @@ PODS: - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy + - integration_test (0.0.1): + - Flutter - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) - nanopb/encode (= 3.30910.0) @@ -126,6 +128,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) SPEC REPOS: @@ -158,6 +161,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_appauth/ios" flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" @@ -178,6 +183,7 @@ SPEC CHECKSUMS: flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf diff --git a/mobile/lib/core/auth/auth_session_controller.dart b/mobile/lib/core/auth/auth_session_controller.dart index 3d1d165..84b34ea 100644 --- a/mobile/lib/core/auth/auth_session_controller.dart +++ b/mobile/lib/core/auth/auth_session_controller.dart @@ -20,6 +20,14 @@ class AuthSessionController extends AsyncNotifier { @override Future build() async { + if (RuntimeConfig.e2eAccessToken.isNotEmpty) { + return AuthSession( + accessToken: RuntimeConfig.e2eAccessToken, + refreshToken: null, + idToken: null, + expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)), + ); + } try { return await _restore(); } catch (_) { diff --git a/mobile/lib/core/config/runtime_config.dart b/mobile/lib/core/config/runtime_config.dart index 5502137..c0a590b 100644 --- a/mobile/lib/core/config/runtime_config.dart +++ b/mobile/lib/core/config/runtime_config.dart @@ -7,6 +7,11 @@ class RuntimeConfig { static const _configuredOidcIssuer = String.fromEnvironment( 'AIOA_OIDC_ISSUER', ); + static const e2eAccessToken = String.fromEnvironment('AIOA_E2E_ACCESS_TOKEN'); + static const e2eEmployeeToken = String.fromEnvironment( + 'AIOA_E2E_EMPLOYEE_TOKEN', + ); + static const e2eRole = String.fromEnvironment('AIOA_E2E_ROLE'); static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty ? _configuredApiBaseUrl diff --git a/mobile/lib/core/forms/presentation/dynamic_form_card.dart b/mobile/lib/core/forms/presentation/dynamic_form_card.dart index 61f6a82..117acf5 100644 --- a/mobile/lib/core/forms/presentation/dynamic_form_card.dart +++ b/mobile/lib/core/forms/presentation/dynamic_form_card.dart @@ -98,6 +98,7 @@ class DynamicFormCard extends StatelessWidget { onChanged: (value) => onChanged(control.field, value), ), FormControlType.dateTime => _DateTimeControl( + key: ValueKey(control.field), label: label, value: state.values[control.field] as String?, errorText: error, @@ -109,6 +110,7 @@ class DynamicFormCard extends StatelessWidget { class _DateTimeControl extends StatelessWidget { const _DateTimeControl({ + super.key, required this.label, required this.value, required this.errorText, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 03978f7..f1d7031 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -246,6 +246,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "12.0.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -336,6 +341,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -384,6 +394,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -600,6 +615,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.5.2" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.5" pub_semver: dependency: transitive description: @@ -773,6 +796,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: @@ -869,6 +900,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8cfb35b..b771eb2 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -46,6 +46,8 @@ dependencies: shared_preferences: ^2.5.3 dev_dependencies: + integration_test: + sdk: flutter flutter_test: sdk: flutter diff --git a/scripts/README.md b/scripts/README.md index e34167f..85ceacf 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,3 +5,5 @@ `verify-local-auth.sh` 使用 Keycloak Realm 中明确标记为仅限本地开发的账号,注册脚本设备并验证健康检查、Token 获取、设备会话和 `/api/v1/me`。不得把该脚本及其测试凭据用于共享或生产环境。 `verify-all.sh` 执行后端测试、AI 服务测试、Flutter 格式/分析/测试、OpenAPI YAML 和 Git 空白检查。设置 `AIOA_FULL_BUILD=1` 后还会构建 Android Debug APK,并在 macOS 上构建 iOS Simulator App。 + +`test-ios-mvp.sh` 在已启动的 iOS Simulator 上依次执行员工、部门主管和 OA 管理员真实前端业务场景。测试连接本地 Keycloak、后端、PostgreSQL 和 Flowable,并自动构造互相隔离的业务数据。 diff --git a/scripts/test-ios-mvp.sh b/scripts/test-ios-mvp.sh new file mode 100755 index 0000000..b61023f --- /dev/null +++ b/scripts/test-ios-mvp.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +keycloak_url="${KEYCLOAK_URL:-http://localhost:8081}" +backend_url="${BACKEND_URL:-http://localhost:8080}" +device_id="${IOS_DEVICE_ID:-$(xcrun simctl list devices booted -j | jq -r '.devices[][] | select(.state == "Booted") | .udid' | head -1)}" + +if [[ -z "${device_id}" ]]; then + echo 'No booted iOS simulator. Boot one or set IOS_DEVICE_ID.' >&2 + exit 1 +fi + +curl --fail --silent "${backend_url}/actuator/health" >/dev/null + +token() { + curl --fail --silent --show-error \ + -X POST "${keycloak_url}/realms/aioa/protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode client_id=aioa-mobile \ + --data-urlencode grant_type=password \ + --data-urlencode "username=$1" \ + --data-urlencode "password=$2" | jq -r .access_token +} + +run_role() { + local role="$1" + local access_token="$2" + local employee_access_token="$3" + echo "Running iOS MVP scenario: ${role}" + ( + cd "${root_dir}/mobile" + flutter test integration_test/mvp_business_test.dart \ + -d "${device_id}" \ + --dart-define="AIOA_E2E_ACCESS_TOKEN=${access_token}" \ + --dart-define="AIOA_E2E_EMPLOYEE_TOKEN=${employee_access_token}" \ + --dart-define="AIOA_E2E_ROLE=${role}" \ + --dart-define="AIOA_API_BASE_URL=${backend_url}/api/v1" + ) +} + +employee_token="$(token employee 'Employee123!')" +run_role employee "${employee_token}" "${employee_token}" + +employee_token="$(token employee 'Employee123!')" +manager_token="$(token manager 'Manager123!')" +run_role manager "${manager_token}" "${employee_token}" + +admin_token="$(token admin 'Admin123!')" +run_role admin "${admin_token}" "${employee_token}" + +echo 'All iOS MVP frontend business scenarios passed.'