Files
DocsGPT/tests/api/user/test_tasks.py
T
Alex adb6963523 fix(rename): address review on the package rename
- CI installs the backend requirements from docsgpt/; the old cd into
  application/ silently installed nothing.
- The root .dockerignore re-admits only application/__init__.py. An upgraded
  checkout may still hold gitignored application/{inputs,indexes,vectors,.env}
  from the old layout, and the directory rule shipped them into the image.
- The compose files keep the host bind mounts on application/{indexes,inputs,
  vectors}, so an upgrade does not start with empty data. The move comes with
  the packaging work, together with an upgrade note.
- The alias loader puts the real docsgpt spec back on the shared module object
  after import (the import machinery stamped the alias spec on it, which made
  importlib.reload rename the module and skip re-execution) and delegates
  get_code/get_source/get_filename to the target loader, so
  python -m application.<name> runs.
- Each legacy application.* task name is registered as its own task object,
  a subclass carrying the old name. Registering the same object under two
  keys made Celery's tracer log every run under whichever name it built last.
- The redbeat key prefix stays redbeat:docsgpt:; the three schedule_syncs
  entries get stable names instead. redbeat tracks its static entries and
  deletes the ones that vanish from beat_schedule at start-up, and rewrites the
  task path of named entries in place, so neither a prefix bump nor a cleanup
  pass is needed (checked against redbeat 2.4.2 with a seeded Redis).
2026-09-07 12:02:07 +01:00

951 lines
33 KiB
Python

from contextlib import contextmanager
from datetime import timedelta
from unittest.mock import ANY, MagicMock, patch
import pytest
@contextmanager
def _patch_decorator_db(conn):
"""Route the decorator's own ``db_session`` / ``db_readonly`` at ``conn``."""
@contextmanager
def _yield():
yield conn
with patch(
"docsgpt.api.user.idempotency.db_session", _yield
), patch(
"docsgpt.api.user.idempotency.db_readonly", _yield
):
yield
class TestIngestTask:
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.ingest_worker")
def test_calls_ingest_worker(self, mock_worker):
from docsgpt.api.user.tasks import ingest
mock_worker.return_value = {"status": "ok"}
result = ingest("dir", ["pdf"], "job1", "user1", "/path", "file.pdf")
mock_worker.assert_called_once_with(
ANY, "dir", ["pdf"], "job1", "/path", "file.pdf", "user1",
file_name_map=None, config=None, idempotency_key=None, source_id=None,
)
assert result == {"status": "ok"}
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.ingest_worker")
def test_passes_file_name_map(self, mock_worker):
from docsgpt.api.user.tasks import ingest
mock_worker.return_value = {"status": "ok"}
name_map = {"a.pdf": "b.pdf"}
ingest("dir", ["pdf"], "job1", "user1", "/path", "file.pdf",
file_name_map=name_map)
mock_worker.assert_called_once_with(
ANY, "dir", ["pdf"], "job1", "/path", "file.pdf", "user1",
file_name_map=name_map, config=None, idempotency_key=None,
source_id=None,
)
class TestIngestRemoteTask:
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.remote_worker")
def test_calls_remote_worker(self, mock_worker):
from docsgpt.api.user.tasks import ingest_remote
mock_worker.return_value = {"status": "ok"}
result = ingest_remote({"url": "http://x"}, "job1", "user1", "web")
mock_worker.assert_called_once_with(
ANY, {"url": "http://x"}, "job1", "user1", "web",
config=None, idempotency_key=None, source_id=None,
)
assert result == {"status": "ok"}
class TestReingestSourceTask:
@pytest.mark.unit
@patch("docsgpt.worker.reingest_source_worker")
def test_calls_reingest_worker(self, mock_worker):
from docsgpt.api.user.tasks import reingest_source_task
mock_worker.return_value = {"status": "ok"}
result = reingest_source_task("source123", "user1")
mock_worker.assert_called_once_with(ANY, "source123", "user1")
assert result == {"status": "ok"}
class TestConvertSourceToWikiTask:
@pytest.mark.unit
@patch("docsgpt.worker.convert_source_to_wiki_worker")
def test_calls_convert_worker(self, mock_worker):
from docsgpt.api.user.tasks import convert_source_to_wiki
mock_worker.return_value = {"status": "converted"}
result = convert_source_to_wiki("source123", "user1")
mock_worker.assert_called_once_with(ANY, "source123", "user1")
assert result == {"status": "converted"}
class TestExtractGraphTask:
@pytest.mark.unit
@patch("docsgpt.worker.extract_graph_worker")
def test_calls_extract_graph_worker(self, mock_worker):
from docsgpt.api.user.tasks import extract_graph
mock_worker.return_value = {"nodes": 2, "edges": 1}
result = extract_graph("source123", "user1")
mock_worker.assert_called_once_with(ANY, "source123", "user1")
assert result == {"nodes": 2, "edges": 1}
@pytest.mark.unit
def test_repeat_with_same_key_short_circuits(self, pg_conn):
from docsgpt.api.user import tasks
calls: list[str] = []
def _fake_worker(self, source_id, user):
calls.append(source_id)
return {"nodes": 1, "edges": 0}
with _patch_decorator_db(pg_conn), patch(
"docsgpt.worker.extract_graph_worker", _fake_worker
):
first = tasks.extract_graph(
"src-g", "user1", idempotency_key="extract-graph:src-g",
)
second = tasks.extract_graph(
"src-g", "user1", idempotency_key="extract-graph:src-g",
)
assert first == second
assert len(calls) == 1
class TestScheduleSyncsTask:
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.sync_worker")
def test_calls_sync_worker(self, mock_worker):
from docsgpt.api.user.tasks import schedule_syncs
mock_worker.return_value = {"status": "ok"}
result = schedule_syncs("daily")
mock_worker.assert_called_once_with(ANY, "daily")
assert result == {"status": "ok"}
class TestSyncSourceTask:
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.sync")
def test_calls_sync(self, mock_sync):
from docsgpt.api.user.tasks import sync_source
mock_sync.return_value = {"status": "ok"}
result = sync_source(
{"data": 1}, "job1", "user1", "web", "daily", "classic", "doc1"
)
mock_sync.assert_called_once_with(
ANY, {"data": 1}, "job1", "user1", "web", "daily", "classic", "doc1"
)
assert result == {"status": "ok"}
class TestStoreAttachmentTask:
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.attachment_worker")
def test_calls_attachment_worker(self, mock_worker):
from docsgpt.api.user.tasks import store_attachment
mock_worker.return_value = {"status": "ok"}
result = store_attachment({"file": "info"}, "user1")
mock_worker.assert_called_once_with(ANY, {"file": "info"}, "user1")
assert result == {"status": "ok"}
@pytest.mark.unit
def test_data_errors_are_not_autoretried(self):
# A DataError is deterministic (poison payload) — retrying it five
# times just multiplies log noise for the same terminal failure.
from sqlalchemy.exc import DataError
from docsgpt.api.user.tasks import store_attachment
assert DataError in getattr(store_attachment, "dont_autoretry_for", ())
class TestProcessAgentWebhookTask:
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.agent_webhook_worker")
def test_calls_agent_webhook_worker(self, mock_worker):
from docsgpt.api.user.tasks import process_agent_webhook
mock_worker.return_value = {"status": "ok"}
result = process_agent_webhook("agent123", {"event": "test"})
mock_worker.assert_called_once_with(ANY, "agent123", {"event": "test"})
assert result == {"status": "ok"}
class TestIngestConnectorTask:
@pytest.mark.unit
@patch("docsgpt.worker.ingest_connector")
def test_calls_ingest_connector_defaults(self, mock_worker):
from docsgpt.api.user.tasks import ingest_connector_task
mock_worker.return_value = {"status": "ok"}
result = ingest_connector_task("job1", "user1", "gdrive")
mock_worker.assert_called_once_with(
ANY,
"job1",
"user1",
"gdrive",
session_token=None,
file_ids=None,
folder_ids=None,
recursive=True,
retriever="classic",
operation_mode="upload",
doc_id=None,
sync_frequency="never",
config=None,
idempotency_key=None,
source_id=None,
)
assert result == {"status": "ok"}
@pytest.mark.unit
@patch("docsgpt.worker.ingest_connector")
def test_calls_ingest_connector_custom(self, mock_worker):
from docsgpt.api.user.tasks import ingest_connector_task
mock_worker.return_value = {"status": "ok"}
result = ingest_connector_task(
"job1",
"user1",
"sharepoint",
session_token="tok",
file_ids=["f1"],
folder_ids=["d1"],
recursive=False,
retriever="duckdb",
operation_mode="sync",
doc_id="doc1",
sync_frequency="daily",
)
mock_worker.assert_called_once_with(
ANY,
"job1",
"user1",
"sharepoint",
session_token="tok",
file_ids=["f1"],
folder_ids=["d1"],
recursive=False,
retriever="duckdb",
operation_mode="sync",
doc_id="doc1",
sync_frequency="daily",
config=None,
idempotency_key=None,
source_id=None,
)
assert result == {"status": "ok"}
class TestSetupPeriodicTasks:
@pytest.mark.unit
def test_every_entry_has_a_stable_name(self):
"""Unnamed entries get keyed by the task path, which redbeat cannot update in place when it changes."""
from docsgpt.api.user.tasks import setup_periodic_tasks
sender = MagicMock()
setup_periodic_tasks(sender)
names = [call.kwargs.get("name") for call in sender.add_periodic_task.call_args_list]
assert all(names), names
assert len(set(names)) == len(names), names
assert names[:3] == ["schedule-syncs-daily", "schedule-syncs-weekly", "schedule-syncs-monthly"]
@pytest.mark.unit
def test_registers_periodic_tasks(self):
from docsgpt.api.user.tasks import setup_periodic_tasks
sender = MagicMock()
setup_periodic_tasks(sender)
assert sender.add_periodic_task.call_count == 14
calls = sender.add_periodic_task.call_args_list
# daily
assert calls[0][0][0] == timedelta(days=1)
# weekly
assert calls[1][0][0] == timedelta(weeks=1)
# monthly
assert calls[2][0][0] == timedelta(days=30)
# pending_tool_state TTL cleanup (60s)
assert calls[3][0][0] == timedelta(seconds=60)
assert calls[3][1].get("name") == "cleanup-pending-tool-state"
# idempotency dedup TTL cleanup (1h)
assert calls[4][0][0] == timedelta(hours=1)
assert calls[4][1].get("name") == "cleanup-idempotency-dedup"
# reconciliation sweep (30s)
assert calls[5][0][0] == timedelta(seconds=30)
assert calls[5][1].get("name") == "reconciliation"
# version-check (every 7h)
assert calls[6][0][0] == timedelta(hours=7)
# message_events retention sweep (24h)
assert calls[7][0][0] == timedelta(hours=24)
assert calls[7][1].get("name") == "cleanup-message-events"
# guardrail_events retention sweep (24h)
assert calls[8][0][0] == timedelta(hours=24)
assert calls[8][1].get("name") == "cleanup-guardrail-events"
# orphan memories sweep (24h)
assert calls[9][0][0] == timedelta(hours=24)
assert calls[9][1].get("name") == "cleanup-orphan-memories"
# scheduler dispatcher
assert calls[10][1].get("name") == "dispatch-scheduled-runs"
# schedule runs cleanup (24h)
assert calls[11][0][0] == timedelta(hours=24)
assert calls[11][1].get("name") == "cleanup-schedule-runs"
# sandbox session reaper (60s)
assert calls[12][0][0] == timedelta(seconds=60)
assert calls[12][1].get("name") == "reap-sandbox-sessions"
# stale workflow-run reaper (5m)
assert calls[13][0][0] == timedelta(seconds=300)
assert calls[13][1].get("name") == "reap-stale-workflow-runs"
class TestMcpOauthTask:
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.mcp_oauth")
def test_calls_mcp_oauth(self, mock_worker):
from docsgpt.api.user.tasks import mcp_oauth_task
mock_worker.return_value = {"url": "http://auth"}
result = mcp_oauth_task({"server": "mcp"}, "user1")
mock_worker.assert_called_once_with(ANY, {"server": "mcp"}, "user1")
assert result == {"url": "http://auth"}
class TestParseDocumentTask:
"""parse_document runs on the parsing queue under a bounded time limit."""
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.parse_document_worker")
def test_calls_parse_document_worker(self, mock_worker):
from docsgpt.api.user.tasks import parse_document
mock_worker.return_value = {"status": "ok", "content": "hi"}
result = parse_document(
"art-1", {"conversation_id": "c1"}, "user1", {"output": "markdown"},
)
mock_worker.assert_called_once_with(
ANY, "art-1", {"conversation_id": "c1"}, "user1",
{"output": "markdown"},
)
assert result == {"status": "ok", "content": "hi"}
@pytest.mark.unit
@patch("docsgpt.api.user.tasks.parse_document_worker")
def test_soft_time_limit_returns_clean_error(self, mock_worker):
from celery.exceptions import SoftTimeLimitExceeded
from docsgpt.api.user.tasks import parse_document
mock_worker.side_effect = SoftTimeLimitExceeded("parse")
# The task must swallow the soft-limit signal and return the worker's
# clean error shape so the parsing-worker slot frees instead of crashing.
result = parse_document("art-1", {"conversation_id": "c1"}, "user1")
assert result["status"] == "error"
assert "timed out" in result["error"]
@pytest.mark.unit
def test_time_limits_derived_from_document_parse_timeout(self):
from docsgpt.api.user.tasks import parse_document
from docsgpt.core.settings import settings
assert parse_document.soft_time_limit == settings.DOCUMENT_PARSE_TIMEOUT
assert parse_document.time_limit == settings.DOCUMENT_PARSE_TIMEOUT + 30
# Hard limit must exceed the soft limit so the handler can unwind first.
assert parse_document.time_limit > parse_document.soft_time_limit
class TestDurableTaskRetryPolicy:
"""The long-running tasks share a uniform retry policy."""
@pytest.mark.unit
@pytest.mark.parametrize(
"task_name",
[
"ingest",
"ingest_remote",
"reingest_source_task",
"store_attachment",
"process_agent_webhook",
"ingest_connector_task",
"reembed_wiki_page",
"convert_source_to_wiki",
"extract_graph",
],
)
def test_task_has_retry_config(self, task_name):
import docsgpt.api.user.tasks as tasks_module
task = getattr(tasks_module, task_name)
assert task.acks_late is True
assert Exception in task.autoretry_for
assert task.retry_backoff == 60
assert task.max_retries == 3
# ``retry_kwargs`` is deliberately unset: celery mutates that dict in
# place on every retry, so sharing one across the decorators would
# race. See the DURABLE_TASK comment in docsgpt/api/user/tasks.py.
assert not getattr(task, "retry_kwargs", None)
@pytest.mark.unit
@pytest.mark.parametrize(
"task_name",
[
"schedule_syncs",
"sync_source",
"mcp_oauth_task",
"cleanup_pending_tool_state",
"reconciliation_task",
"version_check_task",
"cleanup_orphan_memories",
],
)
def test_short_periodic_tasks_have_no_retry_config(self, task_name):
import docsgpt.api.user.tasks as tasks_module
task = getattr(tasks_module, task_name)
assert not getattr(task, "autoretry_for", None)
class TestProcessAgentWebhookIdempotency:
"""Wrapper short-circuits a second call with the same key on the durable webhook task."""
@pytest.mark.unit
def test_repeat_with_same_key_short_circuits(self, pg_conn):
from docsgpt.api.user.tasks import process_agent_webhook
worker_calls = []
def _fake_worker(self, agent_id, payload):
worker_calls.append((agent_id, payload))
return {"status": "success", "result": {"answer": "ok"}}
with _patch_decorator_db(pg_conn), patch(
"docsgpt.api.user.tasks.agent_webhook_worker",
side_effect=_fake_worker,
):
first = process_agent_webhook(
"agent", {"event": "x"}, idempotency_key="dur-k1",
)
second = process_agent_webhook(
"agent", {"event": "x"}, idempotency_key="dur-k1",
)
assert first == {"status": "success", "result": {"answer": "ok"}}
assert second == first
assert len(worker_calls) == 1
class TestCleanupPendingToolState:
"""Janitor reverts stale 'resuming' rows and deletes TTL-expired rows."""
@pytest.mark.unit
def test_reverts_stale_and_deletes_expired(self, pg_conn):
from sqlalchemy import text as _text
from docsgpt.api.user.tasks import cleanup_pending_tool_state
from docsgpt.storage.db.repositories.conversations import (
ConversationsRepository,
)
from docsgpt.storage.db.repositories.pending_tool_state import (
PendingToolStateRepository,
)
repo = PendingToolStateRepository(pg_conn)
def _sample() -> dict:
return {
"messages": [],
"pending_tool_calls": [],
"tools_dict": {},
"tool_schemas": [],
"agent_config": {},
}
# Pending and fresh — should be left alone.
c1 = ConversationsRepository(pg_conn).create("u", "fresh-pending")
repo.save_state(c1["id"], "u", **_sample())
# Pending but already expired — should be deleted.
c2 = ConversationsRepository(pg_conn).create("u", "expired-pending")
expired_state = _sample()
expired_state["agent_config"] = {
"reserved_message_id": "22222222-2222-2222-2222-222222222222"
}
repo.save_state(c2["id"], "u", **expired_state, ttl_seconds=0)
# Resuming within grace — should stay 'resuming'.
c3 = ConversationsRepository(pg_conn).create("u", "fresh-resuming")
repo.save_state(c3["id"], "u", **_sample())
repo.mark_resuming(c3["id"], "u")
# Resuming past grace — should revert to 'pending'.
c4 = ConversationsRepository(pg_conn).create("u", "stale-resuming")
repo.save_state(c4["id"], "u", **_sample())
repo.mark_resuming(c4["id"], "u")
pg_conn.execute(
_text(
"UPDATE pending_tool_state "
"SET resumed_at = clock_timestamp() "
" - make_interval(secs => 660) "
"WHERE conversation_id = CAST(:conv_id AS uuid)"
),
{"conv_id": c4["id"]},
)
from contextlib import contextmanager
@contextmanager
def _fake_begin():
yield pg_conn
fake_engine = MagicMock()
fake_engine.begin = _fake_begin
with patch(
"docsgpt.storage.db.engine.get_engine",
return_value=fake_engine,
), patch(
"docsgpt.api.answer.services.conversation_service."
"ConversationService.finalize_message",
) as finalize_expired:
result = cleanup_pending_tool_state.run()
assert result["reverted"] == 1
assert result["deleted"] == 1
finalize_expired.assert_called_once()
assert finalize_expired.call_args.args[0] == (
"22222222-2222-2222-2222-222222222222"
)
assert finalize_expired.call_args.kwargs["status"] == "failed"
# Final state assertions.
assert repo.load_state(c1["id"], "u")["status"] == "pending"
assert repo.load_state(c2["id"], "u") is None
assert repo.load_state(c3["id"], "u") is None
assert repo.load_state_any(c3["id"], "u")["status"] == "resuming"
c4_row = repo.load_state(c4["id"], "u")
assert c4_row["status"] == "pending"
assert c4_row["resumed_at"] is None
@pytest.mark.unit
def test_skips_when_postgres_uri_missing(self, monkeypatch):
from docsgpt.api.user.tasks import cleanup_pending_tool_state
from docsgpt.core.settings import settings
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
result = cleanup_pending_tool_state.run()
assert result == {
"deleted": 0,
"reverted": 0,
"skipped": "POSTGRES_URI not set",
}
class TestCleanupMessageEventsTask:
"""Retention janitor delegates to MessageEventsRepository.cleanup_older_than."""
@pytest.mark.unit
def test_skips_when_postgres_uri_missing(self, monkeypatch):
from docsgpt.api.user.tasks import cleanup_message_events
from docsgpt.core.settings import settings
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
result = cleanup_message_events.run()
assert result == {"deleted": 0, "skipped": "POSTGRES_URI not set"}
@pytest.mark.unit
def test_deletes_rows_past_retention_window(self, pg_conn, monkeypatch):
import uuid
from sqlalchemy import text as _text
from docsgpt.api.user.tasks import cleanup_message_events
from docsgpt.core.settings import settings
from docsgpt.storage.db.repositories.message_events import (
MessageEventsRepository,
)
# Seed parent rows so the FK on message_events holds.
user_id = f"user-{uuid.uuid4().hex[:8]}"
conv_id = uuid.uuid4()
msg_id = uuid.uuid4()
pg_conn.execute(
_text("INSERT INTO users (user_id) VALUES (:u)"),
{"u": user_id},
)
pg_conn.execute(
_text(
"INSERT INTO conversations (id, user_id, name) "
"VALUES (:id, :u, 'test')"
),
{"id": conv_id, "u": user_id},
)
pg_conn.execute(
_text(
"INSERT INTO conversation_messages (id, conversation_id, "
"user_id, position) VALUES (:id, :c, :u, 0)"
),
{"id": msg_id, "c": conv_id, "u": user_id},
)
repo = MessageEventsRepository(pg_conn)
repo.record(str(msg_id), 0, "answer", {"chunk": "stale"})
repo.record(str(msg_id), 1, "answer", {"chunk": "fresh"})
# Backdate seq=0 past the default 14-day retention so the
# janitor catches it; seq=1 stays at "now" and must survive.
pg_conn.execute(
_text(
"UPDATE message_events SET created_at = now() - interval '20 days' "
"WHERE message_id = CAST(:id AS uuid) AND sequence_no = 0"
),
{"id": str(msg_id)},
)
monkeypatch.setattr(
settings, "POSTGRES_URI", "postgresql://stub", raising=False
)
@contextmanager
def _fake_begin():
yield pg_conn
fake_engine = MagicMock()
fake_engine.begin = _fake_begin
with patch(
"docsgpt.storage.db.engine.get_engine",
return_value=fake_engine,
):
result = cleanup_message_events.run()
assert result == {
"deleted": 1,
# Supersede tombstones ride the same retention beat.
"superseded_deleted": 0,
"ttl_days": settings.MESSAGE_EVENTS_RETENTION_DAYS,
}
# Only the fresh row survives.
rows = repo.read_after(str(msg_id))
assert [r["sequence_no"] for r in rows] == [1]
class TestCleanupOrphanMemoriesTask:
"""Sweeps orphan memories from the FK-to-trigger orphan window."""
@pytest.mark.unit
def test_skips_when_postgres_uri_missing(self, monkeypatch):
from docsgpt.api.user.tasks import cleanup_orphan_memories
from docsgpt.core.settings import settings
monkeypatch.setattr(settings, "POSTGRES_URI", None, raising=False)
result = cleanup_orphan_memories.run()
assert result == {"deleted": 0, "skipped": "POSTGRES_URI not set"}
@pytest.mark.unit
def test_deletes_orphan_keeps_synthetic_and_live(
self, pg_conn, monkeypatch
):
import uuid
from sqlalchemy import text as _text
from docsgpt.agents.default_tools import default_tool_id
from docsgpt.api.user.tasks import cleanup_orphan_memories
from docsgpt.core.settings import settings
from docsgpt.storage.db.repositories.memories import (
MemoriesRepository,
)
repo = MemoriesRepository(pg_conn)
synthetic_id = default_tool_id("memory")
live_id = str(
pg_conn.execute(
_text(
"INSERT INTO user_tools (user_id, name) "
"VALUES ('u-task-mem', 'memory') RETURNING id"
)
).scalar()
)
orphan_id = str(uuid.uuid4())
repo.upsert("u-task-mem", synthetic_id, "/syn.txt", "keep")
repo.upsert("u-task-mem", live_id, "/live.txt", "keep")
repo.upsert("u-task-mem", orphan_id, "/orphan.txt", "drop")
monkeypatch.setattr(
settings, "POSTGRES_URI", "postgresql://stub", raising=False
)
@contextmanager
def _fake_begin():
yield pg_conn
fake_engine = MagicMock()
fake_engine.begin = _fake_begin
with patch(
"docsgpt.storage.db.engine.get_engine",
return_value=fake_engine,
):
result = cleanup_orphan_memories.run()
assert result == {"deleted": 1}
assert repo.get_by_path("u-task-mem", synthetic_id, "/syn.txt")
assert repo.get_by_path("u-task-mem", live_id, "/live.txt")
assert repo.get_by_path("u-task-mem", orphan_id, "/orphan.txt") is None
class TestIngestIdempotency:
"""Same short-circuit applies to the ingest task path."""
@pytest.mark.unit
def test_repeat_with_same_key_short_circuits(self, pg_conn):
from docsgpt.api.user.tasks import ingest
worker_calls = []
def _fake_worker(self, directory, formats, job_name, file_path,
filename, user, file_name_map=None, config=None,
idempotency_key=None, source_id=None):
worker_calls.append(filename)
return {"status": "ok", "directory": directory}
with _patch_decorator_db(pg_conn), patch(
"docsgpt.api.user.tasks.ingest_worker",
side_effect=_fake_worker,
):
first = ingest(
"dir", ["pdf"], "job1", "user1", "/path", "file.pdf",
idempotency_key="dur-ing-1",
)
second = ingest(
"dir", ["pdf"], "job1", "user1", "/path", "file.pdf",
idempotency_key="dur-ing-1",
)
assert first == second
assert first == {"status": "ok", "directory": "dir"}
assert len(worker_calls) == 1
class TestIngestPoisonEvent:
"""The poison hook publishes a terminal source.ingest.failed so the
upload toast resolves instead of hanging on "training".
"""
@pytest.mark.unit
def test_publishes_failed_event(self):
from docsgpt.api.user.tasks import _emit_ingest_poison_event
published = []
def _fake_publish(user, event_type, payload, *, scope=None):
published.append((user, event_type, payload, scope))
with patch(
"docsgpt.events.publisher.publish_user_event",
side_effect=_fake_publish,
):
_emit_ingest_poison_event(
"ingest",
{"user": "u1", "source_id": "src-9", "filename": "doc.pdf"},
)
assert len(published) == 1
user, event_type, payload, scope = published[0]
assert user == "u1"
assert event_type == "source.ingest.failed"
assert payload["source_id"] == "src-9"
assert payload["filename"] == "doc.pdf"
assert payload["operation"] == "upload"
assert scope == {"kind": "source", "id": "src-9"}
@pytest.mark.unit
def test_skips_when_source_id_missing(self):
from docsgpt.api.user.tasks import _emit_ingest_poison_event
with patch(
"docsgpt.events.publisher.publish_user_event",
) as mock_publish:
_emit_ingest_poison_event("ingest", {"user": "u1"})
mock_publish.assert_not_called()
@pytest.mark.unit
def test_reingest_uses_reingest_operation(self):
from docsgpt.api.user.tasks import _emit_ingest_poison_event
published = []
with patch(
"docsgpt.events.publisher.publish_user_event",
side_effect=lambda *a, **k: published.append((a, k)),
):
_emit_ingest_poison_event(
"reingest_source_task",
{"user": "u1", "source_id": "src-r"},
)
assert published[0][0][2]["operation"] == "reingest"
@pytest.mark.unit
def test_bare_worker_consumes_app_and_parsing_queues():
"""task_queues declares every queue, so a worker started without -Q serves
both app tasks and document parsing — a -Q-less dev worker must never
silently strand attachment uploads or parse_document tasks."""
import docsgpt.celeryconfig as celeryconfig
from docsgpt.core.settings import settings
names = {queue.name for queue in celeryconfig.task_queues}
assert "docsgpt" in names
assert settings.DOCUMENT_PARSE_QUEUE in names
class TestParseTimeoutForSize:
"""The awaited parse window is floored at the base timeout, scales with size, and is capped."""
@pytest.mark.unit
def test_unknown_size_uses_the_base_timeout(self):
from docsgpt.api.user.tasks import parse_timeout_for_size
from docsgpt.core.settings import settings
base = float(settings.DOCUMENT_PARSE_TIMEOUT)
assert parse_timeout_for_size(None) == base
assert parse_timeout_for_size(0) == base
# A negative/garbage size can never shrink the window below the floor.
assert parse_timeout_for_size(-1) == base
assert parse_timeout_for_size("nope") == base
@pytest.mark.unit
def test_window_grows_with_the_document_size(self, monkeypatch):
from docsgpt.api.user import tasks
from docsgpt.core.settings import settings
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120, raising=False)
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT_PER_MB", 60, raising=False)
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT_MAX", 900, raising=False)
assert tasks.parse_timeout_for_size(1024 * 1024) == 180.0
assert tasks.parse_timeout_for_size(5 * 1024 * 1024) == 420.0
# Fractional sizes scale proportionally, never rounded down to the floor.
assert tasks.parse_timeout_for_size(512 * 1024) == 150.0
@pytest.mark.unit
def test_window_is_capped(self, monkeypatch):
from docsgpt.api.user import tasks
from docsgpt.core.settings import settings
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120, raising=False)
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT_PER_MB", 60, raising=False)
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT_MAX", 900, raising=False)
# A huge document cannot pin a parsing-worker slot indefinitely.
assert tasks.parse_timeout_for_size(500 * 1024 * 1024) == 900.0
@pytest.mark.unit
def test_scaling_disabled_by_zero_per_mb(self, monkeypatch):
from docsgpt.api.user import tasks
from docsgpt.core.settings import settings
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT", 120, raising=False)
monkeypatch.setattr(settings, "DOCUMENT_PARSE_TIMEOUT_PER_MB", 0, raising=False)
assert tasks.parse_timeout_for_size(50 * 1024 * 1024) == 120.0
@pytest.mark.unit
def test_task_time_limits_track_the_awaited_window(self):
from docsgpt.api.user.tasks import parse_document, parse_task_time_limits
limits = parse_task_time_limits(420.0)
assert limits == {"soft_time_limit": 420, "time_limit": 450}
# Same grace as the import-time binding, so per-call limits stay comparable.
grace = parse_document.time_limit - parse_document.soft_time_limit
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 docsgpt.api.user import reconciliation
from docsgpt.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 docsgpt.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()
)