mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 02:16:08 +00:00
fix: more test fixes and additions, fix loss on resume
This commit is contained in:
1 parent
e96ff8658c
commit
29f661f3c7
24 files changed
+921
-85
No files matched your search
@@ -143,6 +143,47 @@ class TestAnswerResourcePost:
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["error"] == "Stream error"
|
||||
|
||||
def test_resume_already_in_progress_returns_409(
|
||||
self, answer_client, mock_stream_processor,
|
||||
):
|
||||
"""A raced resume is a conflict the caller can retry, not a 500.
|
||||
|
||||
``ResumeInProgressError`` subclasses ``ValueError`` but reaches this
|
||||
route through ``resume_from_tool_actions``, where the bare
|
||||
``except Exception`` reported it as an opaque 500 with a full
|
||||
traceback on the ERROR channel. ``/stream`` and
|
||||
``/v1/chat/completions`` both answer 409 for the identical condition.
|
||||
"""
|
||||
from application.api.answer.services.continuation_service import (
|
||||
ResumeInProgressError,
|
||||
)
|
||||
|
||||
mock_stream_processor.resume_from_tool_actions.side_effect = (
|
||||
ResumeInProgressError("Resume already in progress for this conversation.")
|
||||
)
|
||||
|
||||
with patch(
|
||||
"application.api.answer.routes.answer.AnswerResource.validate_request",
|
||||
return_value=None,
|
||||
):
|
||||
resp = answer_client.post(
|
||||
"/api/answer",
|
||||
data=json.dumps({
|
||||
"question": "",
|
||||
"conversation_id": _CONV_ID,
|
||||
"tool_actions": [{"call_id": "call_1", "decision": "approved"}],
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
body = resp.get_json()
|
||||
assert body["code"] == "resume_in_progress"
|
||||
assert "Resume already in progress" in body["error"]
|
||||
# The generic handler's opaque text would tell the caller nothing
|
||||
# about retryability.
|
||||
assert body["error"] != "An error occurred processing your request"
|
||||
|
||||
def test_exception_returns_500(self, answer_client, mock_stream_processor):
|
||||
with patch(
|
||||
"application.api.answer.routes.answer.AnswerResource.validate_request",
|
||||
|
||||
@@ -1450,3 +1450,67 @@ class TestStreamingHeartbeatSeed:
|
||||
)
|
||||
|
||||
resource.conversation_service.heartbeat_message.assert_not_called()
|
||||
|
||||
|
||||
class TestCompleteStreamSupersededLookupIsGuarded:
|
||||
"""A DB failure in the supersede lookup must not error a finished turn.
|
||||
|
||||
The ``NOT_FOUND`` branch used to only log. It now consults
|
||||
``was_superseded`` to tell a replaced turn from an orphaned answer — over
|
||||
its own connection, inside the streaming ``try``. Unguarded, a blip there
|
||||
(or a deploy where the app runs ahead of migration 0030 and the
|
||||
``superseded_messages`` table does not exist yet) escapes to the generic
|
||||
handler: the client gets a terminal ``error`` after the whole answer, the
|
||||
row is finalized a second time, a continuation's resume claim is released,
|
||||
and the ``answer_persist_failed`` alert this branch exists to raise is
|
||||
swallowed.
|
||||
"""
|
||||
|
||||
def test_lookup_failure_falls_back_instead_of_erroring_the_stream(
|
||||
self, pg_conn, flask_app, caplog,
|
||||
):
|
||||
import logging
|
||||
|
||||
from application.api.answer.routes.base import BaseAnswerResource
|
||||
from application.api.answer.services.conversation_service import (
|
||||
ConversationService,
|
||||
)
|
||||
from application.storage.db.repositories.conversations import (
|
||||
MessageUpdateOutcome,
|
||||
)
|
||||
|
||||
with flask_app.app_context():
|
||||
resource = BaseAnswerResource()
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.gen.return_value = iter(
|
||||
[{"type": "answer", "answer": "the answer"}]
|
||||
)
|
||||
|
||||
with _patch_db_session(pg_conn), patch.object(
|
||||
ConversationService,
|
||||
"finalize_message",
|
||||
return_value=MessageUpdateOutcome.NOT_FOUND,
|
||||
), patch.object(
|
||||
ConversationService,
|
||||
"was_superseded",
|
||||
side_effect=RuntimeError("server closed the connection"),
|
||||
), caplog.at_level(logging.ERROR):
|
||||
stream = list(
|
||||
resource.complete_stream(
|
||||
question="does the guard hold?",
|
||||
agent=mock_agent,
|
||||
conversation_id=None,
|
||||
user_api_key=None,
|
||||
decoded_token={"sub": "u-superseded-guard"},
|
||||
should_persist=True,
|
||||
model_id="gpt-4",
|
||||
)
|
||||
)
|
||||
|
||||
# The answer was already streamed; the row is gone either way. What
|
||||
# must not happen is retracting it with a terminal error frame.
|
||||
assert [s for s in stream if '"type": "error"' in s] == []
|
||||
# And the alert must still fire, on the orphaned-answer path.
|
||||
assert any(
|
||||
"answer_persist_failed" in r.getMessage() for r in caplog.records
|
||||
)
|
||||
@@ -945,17 +945,20 @@ class TestStalledIngestEvent:
|
||||
class TestPostgresUriMissing:
|
||||
@pytest.mark.unit
|
||||
def test_returns_skip_dict(self, monkeypatch):
|
||||
from application.api.user.reconciliation import run_reconciliation
|
||||
from application.api.user.reconciliation import (
|
||||
run_reconciliation,
|
||||
zero_summary,
|
||||
)
|
||||
from application.core.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
|
||||
|
||||
result = run_reconciliation()
|
||||
assert result == {
|
||||
"messages_failed": 0,
|
||||
"tool_calls_failed": 0,
|
||||
"skipped": "POSTGRES_URI not set",
|
||||
}
|
||||
# Same counters as any other tick, all zero, plus the reason. Pinning
|
||||
# a hand-written literal here is what let the shapes drift apart: this
|
||||
# path reported 3 of the 5 keys and the task's error fallback invented
|
||||
# a sixth that no sweep produces.
|
||||
assert result == {**zero_summary(), "skipped": "POSTGRES_URI not set"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -900,3 +900,38 @@ class TestParseTimeoutForSize:
|
||||
assert limits["time_limit"] - limits["soft_time_limit"] == grace
|
||||
# Never zero/negative, whatever the caller computed.
|
||||
assert parse_task_time_limits(0)["soft_time_limit"] == 1
|
||||
|
||||
|
||||
class TestReconciliationTaskShape:
|
||||
"""The beat's error fallback must report the same counters as a good tick."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_error_fallback_matches_the_real_summary_keys(self):
|
||||
from application.api.user import reconciliation
|
||||
from application.api.user.tasks import reconciliation_task
|
||||
|
||||
with patch.object(
|
||||
reconciliation, "run_reconciliation", side_effect=RuntimeError("db down")
|
||||
):
|
||||
result = reconciliation_task.run()
|
||||
|
||||
assert result["error"] is True
|
||||
# Hand-writing the fallback drifted immediately: it invented
|
||||
# ``attachments_stalled`` (no sweep produces it) and dropped
|
||||
# ``idempotency_pending_failed``, so a failed tick reported counters
|
||||
# that could never appear and hid one that can.
|
||||
counters = {k: v for k, v in result.items() if k != "error"}
|
||||
assert counters == reconciliation.zero_summary()
|
||||
assert all(v == 0 for v in counters.values())
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_skipped_tick_reports_the_same_counters(self, monkeypatch):
|
||||
from application.api.user import reconciliation
|
||||
|
||||
monkeypatch.setattr(reconciliation.settings, "POSTGRES_URI", "", raising=False)
|
||||
result = reconciliation.run_reconciliation()
|
||||
|
||||
assert result["skipped"] == "POSTGRES_URI not set"
|
||||
assert {k: v for k, v in result.items() if k != "skipped"} == (
|
||||
reconciliation.zero_summary()
|
||||
)
|
||||
Reference in new issue
Block a user