"""Automation acceptance checks with isolated persistence and no real device side effects.""" from io import BytesIO import json import time from unittest.mock import AsyncMock import zipfile from fastapi import FastAPI from fastapi.testclient import TestClient from openpyxl import load_workbook import pytest from apps.admin_console.routers import automation as api from apps.admin_console.services import automation_service as service from apps.admin_console.services import report_export as reports from apps.admin_console.services import task_import from apps.admin_console.services.automation_store import AutomationStore from artemis.data_engine.storage import StorageManager @pytest.fixture def isolated(tmp_path, monkeypatch): db = tmp_path / "data.db" StorageManager(db_path=db, base_trace_dir=tmp_path) store = AutomationStore(db) for module in (api, service, reports): monkeypatch.setattr(module, "store", store) monkeypatch.setattr(reports, "TRACES_PATH", tmp_path / "traces") monkeypatch.setattr(reports, "EXPORT_ROOT", tmp_path / "exports") from apps.admin_console.database.repositories.session_repository import SessionRepository from apps.admin_console.database.repositories.step_repository import StepRepository monkeypatch.setattr(reports, "SessionRepository", lambda *a: SessionRepository(db)) monkeypatch.setattr(reports, "StepRepository", lambda *a: StepRepository(db)) return store @pytest.fixture def client(isolated): app = FastAPI() app.include_router(api.router) return TestClient(app) def payload(**extra): return { "name": "批次", "cases": [{"name": "用例甲", "goal": "观察设置"}, {"name": "用例乙", "goal": "观察首页"}], "device_serial": "test-device", **extra, } def workbook_bytes(rows): wb = load_workbook(BytesIO(task_import.template())) for row in rows: wb["任务"].append(row) output = BytesIO() wb.save(output) return output.getvalue() def test_template_preview_rejects_formula_and_bad_json(): data = workbook_bytes( [ ["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"], ["公式", "=SUM(1,2)"], ["参数错误", "测试", "", "", "", "{"], ] ) result = task_import.parse_workbook(data) assert len(result["tasks"]) == 1 assert [e["row"] for e in result["errors"]] == [3, 4] assert not result["valid"] def test_invalid_archive_and_empty_template(): with pytest.raises(ValueError): task_import.parse_workbook(b"not a zip") assert not task_import.parse_workbook(task_import.template())["valid"] def test_preview_does_not_enqueue(client, isolated): response = client.post( "/api/automation/import/preview", files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))}, ) assert response.status_code == 200 assert response.json()["valid"] assert not isolated.jobs() def test_preferences_and_required_device(client): assert client.get("/api/automation/preferences").json()["formats"] == ["docx", "xlsx"] assert client.put("/api/automation/preferences", json={"formats": ["xlsx"]}).status_code == 200 assert client.put("/api/automation/preferences", json={"formats": []}).status_code == 422 assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422 def test_restart_never_repeats_started_work(isolated): batch = service.create_batch(payload()) service.batch_action(batch["id"], "resume") job = isolated.jobs(batch["id"])[0] isolated.save_job({**job, "status": "queued"}) service.recover_batches() assert isolated.get("batch", batch["id"])["status"] == "paused" assert [j["status"] for j in isolated.jobs(batch["id"])] == ["interrupted", "pending"] retry = service.batch_action(batch["id"], "retry_failed") assert retry["status"] == "draft" assert len(isolated.jobs(retry["id"])) == 1 assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"] @pytest.mark.asyncio async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch): from apps.admin_console import server batch = service.create_batch(payload()) service.batch_action(batch["id"], "resume") monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})()) enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]}) monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue) monkeypatch.setattr( "artemis.runtime.device_pool.validate_explicit_serial_async", AsyncMock(return_value=None) ) from artemis.runtime.device_pool import DeviceStatus monkeypatch.setattr( "artemis.runtime.device_pool.try_list_devices_async", AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]), ) await service.tick_batches() await service.tick_batches() assert enqueue.await_count == 1 assert enqueue.call_args.kwargs["device_serial"] == "test-device" assert isolated.jobs(batch["id"])[1]["status"] == "pending" @pytest.mark.asyncio async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch): batch = service.create_batch(payload()) service.batch_action(batch["id"], "resume") monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})()) monkeypatch.setattr( "artemis.runtime.device_pool.try_list_devices_async", AsyncMock(return_value=[]) ) await service.tick_batches() assert isolated.get("batch", batch["id"])["status"] == "paused" assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"])) @pytest.mark.asyncio async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch): batch = service.create_batch(payload(stop_on_failure=True)) first = isolated.jobs(batch["id"])[0] isolated.save_job({**first, "status": "failed"}) service.batch_action(batch["id"], "resume") await service.tick_batches() assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"] archive = reports.export_batch(batch["id"]) with zipfile.ZipFile(archive) as z: assert len(z.namelist()) == 6 # two summary files + two files per task assert sum(n.endswith(".docx") for n in z.namelist()) == 3 @pytest.mark.asyncio async def test_report_failure_does_not_strand_job(isolated, monkeypatch): job = isolated.jobs(service.create_batch(payload())["id"])[0] monkeypatch.setattr( reports, "export_session", lambda *a: (_ for _ in ()).throw(OSError("disk full")) ) await service.on_terminal({"session_id": job["session_id"]}, "completed") saved = isolated.job(job["session_id"]) assert saved["status"] == "completed" assert "disk full" in saved["report_error"] def test_reports_do_not_execute_formulas_or_invent_pass(isolated): batch = service.create_batch(payload()) job = isolated.jobs(batch["id"])[0] isolated.save_job( {**job, "case": {"name": "=1+1", "goal": '=HYPERLINK("x")'}, "status": "completed"} ) report = reports.session_report(job["session_id"]) assert report["verdict"].startswith("未验证") assert reports.verdict({"tests": {"passed": 2, "failed": 1}}) == "验证失败" assert reports.verdict({"tests": {"passed": 2, "unchecked": 1}}) == "验证不完整" paths = reports.export_session(job["session_id"]) wb = load_workbook(next(p for p in paths if p.suffix == ".xlsx")) assert wb["任务结果"]["B3"].data_type == "s" with zipfile.ZipFile(next(p for p in paths if p.suffix == ".docx")) as doc: assert "未验证".encode() in doc.read("word/document.xml") def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated): batch = service.create_batch(payload()) first, second = isolated.jobs(batch["id"]) isolated.save_job( { **first, "status": "completed", "created_at": 100, "case": {"name": "搜索_%", "goal": "abc", "tags": "回归"}, } ) isolated.save_job({**second, "status": "failed", "created_at": 200}) assert client.get("/api/automation/history", params={"q": "_%"}).json()["total"] == 1 assert ( client.get("/api/automation/history", params={"q": "回归"}).json()["items"][0]["session_id"] == first["session_id"] ) assert ( client.get( "/api/automation/history", params={"start": 150, "end": 250, "status": "failed"} ).json()["total"] == 1 ) assert ( client.get("/api/automation/history", params={"size": 1, "page": 2}).json()["items"][0][ "session_id" ] == first["session_id"] ) def test_batch_submission_is_atomic_and_idempotent(client, isolated): value = payload(request_id="same-request") first = client.post("/api/automation/batches", json=value) second = client.post("/api/automation/batches", json=value) assert first.status_code == second.status_code == 200 assert first.json()["id"] == second.json()["id"] assert len(isolated.jobs()) == 2 assert len(isolated.records("batch")) == 1 conflict = client.post("/api/automation/batches", json={**value, "name": "different"}) assert conflict.status_code == 409 lookup = client.get("/api/automation/batches/by-request/same-request").json() assert lookup["id"] == first.json()["id"] assert len(lookup["jobs"]) == 2 def test_removed_replay_is_inert_but_history_survives(client, isolated): batch = service.create_batch(payload()) job = isolated.jobs(batch["id"])[0] isolated.save_job({**job, "execution_mode": "strict", "workflow_id": "legacy"}) service.recover_batches() assert isolated.get("batch", batch["id"])["status"] == "cancelled" assert all(j["status"] == "cancelled" for j in isolated.jobs(batch["id"])) assert client.post(f"/api/automation/batches/{batch['id']}/resume").status_code == 409 assert client.post(f"/api/automation/batches/{batch['id']}/retry_failed").status_code == 409 assert client.get("/api/automation/workflows").status_code == 404 assert client.get("/api/automation/history").json()["total"] == 2