import json from types import SimpleNamespace from uuid import UUID from fastapi import FastAPI from fastapi.testclient import TestClient import pytest from apps.admin_console.routers import sessions from apps.admin_console.services import exploration_deletion as deletion from apps.admin_console.services import report_export from apps.admin_console.services.automation_store import AutomationStore from artemis.config.exploration import ExplorationConfig from artemis.data_engine.storage import StorageManager from artemis.exploration import store as store_module from artemis.exploration.models import CaseDraft from artemis.exploration.store import ExplorationConflict, ExplorationStore @pytest.fixture def setup(tmp_path, monkeypatch): db = tmp_path / "data.db" traces = tmp_path / "traces" storage = StorageManager(db, traces) repository = ExplorationStore(AutomationStore(db)) monkeypatch.setattr(store_module, "explorations", repository) monkeypatch.setattr(report_export, "TRACES_PATH", traces) monkeypatch.setattr(report_export, "EXPORT_ROOT", tmp_path / "exports") monkeypatch.setattr(deletion, "state", SimpleNamespace(queue_items=[], active_runs={})) monkeypatch.setattr(deletion, "read_state", lambda _: {}) app = FastAPI() app.include_router(sessions.router) session = repository.create("探索页面", ExplorationConfig(), {}, "create") return repository, session["id"], storage, TestClient(app) def make_run(repository, sid, request, *, kind="exploration", finished=True): run = repository.reserve_run( sid, request, kind=kind, goal="观察", task_config={}, case_version=1 if kind == "validation" else None, ) if finished: repository.finish_run(run["id"], {"status": "cancelled"}) return run["id"] def test_delete_conversation_removes_all_exploration_runs_but_keeps_reruns(setup): repository, sid, storage, client = setup first = make_run(repository, sid, "first") second = make_run(repository, sid, "continue") repository.save_case(sid, CaseDraft(name="可复用用例", goal="检查页面"), 0, "case") validation = make_run(repository, sid, "validate", kind="validation") unrelated = repository.create("另一探索", ExplorationConfig(), {}, "other")["id"] directories = [] for run_id in (first, second, validation): repository.database.save_job({"session_id": run_id, "status": "cancelled", "position": 1}) with repository.database.connection() as connection: connection.execute( "INSERT INTO sessions (session_id, initial_goal, start_time, status, device_info) VALUES (?, ?, 1, ?, '{}')", (run_id, "观察", "cancelled"), ) connection.commit() for root in (storage.base_trace_dir, report_export.EXPORT_ROOT / "sessions"): folder = root / run_id folder.mkdir(parents=True) (folder / "evidence.txt").write_text("saved", encoding="utf-8") if run_id != validation: directories.append(folder) with repository._transaction() as connection: session = repository._require(connection, sid) session["status"] = "paused" # Resumable conversation, no running process. repository._save_session(connection, session) response = client.post(f"/api/sessions/{first}/delete") assert response.status_code == 200, response.text assert set(response.json()["deleted_session_ids"]) == {first, second} assert not any(folder.exists() for folder in directories) assert (storage.base_trace_dir / validation / "evidence.txt").exists() assert repository.database.job(validation) is not None assert repository.database.job(first) is None assert storage.get_session(UUID(validation)) is not None assert storage.get_session(UUID(first)) is None assert repository.get(unrelated)["goal"] == "另一探索" assert repository.run(validation) is None # Detach it from the deleted conversation. deleted_requests = [ record for record in repository.database.records("exploration_request") if record.get("deleted") ] assert len(deleted_requests) >= 5 assert all("result" not in record for record in deleted_requests) with repository.database.connection() as connection: assert not connection.execute( "SELECT 1 FROM automation_records WHERE kind != 'exploration_request' " "AND json_extract(payload,'$.exploration_id')=?", (sid,), ).fetchone() with pytest.raises(KeyError): repository.get(sid) with pytest.raises(ExplorationConflict, match="已删除"): repository.create("探索页面", ExplorationConfig(), {}, "create") @pytest.mark.parametrize("state", ["running", "paused", "waiting_user", "demonstrating"]) def test_live_execution_cannot_be_deleted_even_through_an_old_run(setup, state): repository, sid, _, client = setup first = make_run(repository, sid, "first") active = make_run(repository, sid, "continue", finished=False) repository.set_run_state(active, state) response = client.post(f"/api/sessions/{first}/delete") assert response.status_code == 409 assert repository.get(sid)["active_run_id"] == active assert repository.run(first) is not None @pytest.mark.parametrize("kind", ["exploration_generation", "exploration_discussion"]) def test_editorial_work_prevents_deletion(setup, kind): repository, sid, _, client = setup run_id = make_run(repository, sid, "first") repository.database.put(kind, "job", {"exploration_id": sid, "status": "running"}) assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409 assert repository.get(sid)["status"] == "cancelled" def test_worker_still_exiting_prevents_deletion(setup, monkeypatch): repository, sid, _, client = setup run_id = make_run(repository, sid, "first") monkeypatch.setattr( deletion, "state", SimpleNamespace(queue_items=[], active_runs={run_id: {}}) ) assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409 assert repository.run(run_id) is not None def test_cleanup_failure_is_retryable_and_blocks_new_work(setup, monkeypatch): repository, sid, storage, client = setup run_id = make_run(repository, sid, "first") original = StorageManager.delete_session def fail(*args): raise OSError("file busy") monkeypatch.setattr(StorageManager, "delete_session", fail) response = client.post(f"/api/sessions/{run_id}/delete") assert response.status_code == 500 with pytest.raises(ExplorationConflict, match="正在删除"): make_run(repository, sid, "restart") monkeypatch.setattr(StorageManager, "delete_session", original) assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200 assert not repository.list() @pytest.mark.asyncio async def test_delayed_discussion_cannot_recreate_deleted_metadata(setup): from artemis.exploration.editorial import discuss repository, sid, _, client = setup run_id = make_run(repository, sid, "first") message = repository.add_message(sid, "不再继续", "message") assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200 with pytest.raises(KeyError): await discuss(repository, sid, message) assert not repository.database.records("exploration_discussion")