Files
Artemis-Eapil/tests/unit/test_exploration_deletion.py
T
chenmingxuan 2328a4df0c fix: improve cross-device evidence and exploration lifecycle
Align Responses cache affinity across execution and summary calls, preserve required checks and evidence uncertainty, and allow safe deletion of inactive explorations while retaining independent reruns.

Validation: backend suite passed except intentional prompt golden drift, then all 17 transcript tests passed after snapshot refresh; 268 frontend tests and production build passed; Ruff, quality ratchet and core type checks passed.
2026-10-17 19:43:20 +08:00

167 lines
7.3 KiB
Python

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")