Files
deerflow-code/offline-backend-20260512/backend/tests/test_deep_research_start_job.py
T
董宏凯andCursor 15a46b903a 增强文档改写配图与深度研究流式稳定性,并完善 8BF/会商工作区。
全文改写支持可选配图与思考过程展示,深度研究接入 DashScope 原生出图并剥离内联 think 标签;8BF 会商补齐第一步工作区与任务卡回退,同时限制热重载扫描范围。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 17:46:11 +08:00

212 lines
7.0 KiB
Python

"""Regression coverage for idempotent Deep Research job submission."""
from types import SimpleNamespace
import pytest
from app.gateway.routers import deep_research
class _SessionStore:
def __init__(self) -> None:
self.updates: list[dict] = []
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
return {
"id": session_id,
"query": "测试主题",
"title": "测试主题",
"config_snapshot": {"mode": "basic"},
}
async def count_active_by_user(self, *, user_id: str) -> int: # noqa: ARG002
return 0
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
self.updates.append(fields)
return {"id": session_id, **fields}
class _JobStore:
async def get_active_for_session(self, session_id: str, *, user_id: str): # noqa: ARG002
return None
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
# Simulate a retry after an earlier request committed the job but did
# not finish responding to the browser.
return {"id": "drj_existing", "status": "queued"}, False
class _Dispatcher:
def __init__(self) -> None:
self.nudges = 0
def nudge(self) -> None:
self.nudges += 1
def _request(*, sessions, jobs, dispatcher):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_session_store=sessions,
deep_research_job_store=jobs,
deep_research_dispatcher=dispatcher,
)
)
)
@pytest.mark.asyncio
async def test_retry_of_existing_queued_job_nudges_dispatcher(monkeypatch):
"""An idempotent retry must recover a queued-but-not-yet-dispatched job."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
sessions = _SessionStore()
dispatcher = _Dispatcher()
request = _request(sessions=sessions, jobs=_JobStore(), dispatcher=dispatcher)
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
request,
)
assert response == {
"jobId": "drj_existing",
"sessionId": "drs_1",
"status": "queued",
"reused": True,
}
assert dispatcher.nudges == 1
assert sessions.updates == [{"status": "running", "active_job_id": "drj_existing"}]
@pytest.mark.asyncio
async def test_queued_job_still_starts_when_session_projection_temporarily_fails(monkeypatch):
"""A committed job is more important than its non-authoritative UI projection."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _ProjectionFailureSessionStore(_SessionStore):
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
raise RuntimeError("temporary database readback failure")
class _NewJobStore(_JobStore):
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
return {"id": "drj_new", "status": "queued"}, True
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
dispatcher = _Dispatcher()
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(entry="legacy"),
_request(
sessions=_ProjectionFailureSessionStore(),
jobs=_NewJobStore(),
dispatcher=dispatcher,
),
)
assert response["jobId"] == "drj_new"
assert response["reused"] is False
assert dispatcher.nudges == 1
@pytest.mark.asyncio
async def test_chat_report_card_can_enable_images_after_collection(monkeypatch):
"""The write-time card override must win over the collection-time default."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _ChatSessionStore(_SessionStore):
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
return {
"id": session_id,
"query": "测试主题",
"title": "测试主题",
"runtime_thread_id": "collector-thread",
"config_snapshot": {
"mode": "basic",
"collection_mode": "chat",
"generate_images": False,
"max_generated_images": 2,
},
}
class _CapturingJobStore(_JobStore):
def __init__(self) -> None:
self.created: dict | None = None
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
self.created = kwargs
return {"id": "drj_images", "status": "queued"}, True
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
sessions = _ChatSessionStore()
jobs = _CapturingJobStore()
response = await deep_research.start_job(
"drs_1",
deep_research.StartJobRequest(
entry="chat",
config={"generate_images": True, "max_generated_images": 3},
),
_request(sessions=sessions, jobs=jobs, dispatcher=_Dispatcher()),
)
assert response["jobId"] == "drj_images"
assert jobs.created is not None
snapshot = jobs.created["input_snapshot"]["config"]
assert snapshot["generate_images"] is True
assert snapshot["max_generated_images"] == 3
# The session snapshot is persisted too, so a reload/replay keeps the
# write-time choice instead of reverting to the creation-time false.
assert any(
update.get("config_snapshot", {}).get("generate_images") is True
for update in sessions.updates
)
@pytest.mark.asyncio
async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch):
"""A stream DB blip must yield a recoverable warning, not an ASGI error group."""
async def fake_require_user(request): # noqa: ANN001
return "u1"
class _StreamJobStore:
async def get(self, job_id: str, *, user_id: str): # noqa: ARG002
return {"id": job_id, "session_id": "drs_1"}
async def get_unscoped(self, job_id: str): # noqa: ARG002
return None
class _UnavailableEventStore:
async def list_after(self, *args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("temporary replica failure")
async def is_connected() -> bool:
return False
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
deep_research_job_store=_StreamJobStore(),
deep_research_event_store=_UnavailableEventStore(),
)
),
is_disconnected=is_connected,
)
response = await deep_research.stream_job("drj_1", request)
chunk = await anext(response.body_iterator)
await response.body_iterator.aclose()
assert "durable_replay_temporarily_unavailable" in chunk