first
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""Shared pytest fixtures for asf-worker tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
WORKER_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(WORKER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(WORKER_ROOT))
|
||||
|
||||
TENANT_ID = UUID("00000000-0000-0000-0000-000000000099")
|
||||
PROJECT_ID = UUID("00000000-0000-0000-0000-000000000088")
|
||||
CORRELATION_ID = "test-correlation-worker"
|
||||
|
||||
|
||||
class FakeJobsCollection:
|
||||
"""In-memory AsyncIOMotorCollection stand-in for claim/persistence tests.
|
||||
|
||||
Mirrors the tiny subset of the Motor API that `_claim_running` and
|
||||
`_mark_job_done` exercise: ``update_one(filter, ops)`` with
|
||||
``matched_count`` on the returned UpdateResult.
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class UpdateResult:
|
||||
matched_count: int = 0
|
||||
modified_count: int = 0
|
||||
upserted_id: object = None
|
||||
|
||||
def __init__(self, docs: dict[UUID, dict] | None = None) -> None:
|
||||
self._docs: dict[UUID, dict] = {k: dict(v) for k, v in (docs or {}).items()}
|
||||
self.last_update_filter: object = None
|
||||
self.last_update_ops: object = None
|
||||
|
||||
async def update_one(self, filt: dict, ops: dict) -> UpdateResult:
|
||||
self.last_update_filter = filt
|
||||
self.last_update_ops = ops
|
||||
jid = filt.get("_id")
|
||||
existing = self._docs.get(jid) if jid is not None else None
|
||||
if existing is None:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
|
||||
# Apply the whole filter object like MongoDB does (simple top-level equality match).
|
||||
for k, v in filt.items():
|
||||
if k == "status" and isinstance(v, dict) and "$in" in v:
|
||||
if existing.get(k) not in v["$in"]:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
elif k == "_id":
|
||||
if existing.get(k) != v:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
else:
|
||||
if existing.get(k) != v:
|
||||
return FakeJobsCollection.UpdateResult(matched_count=0)
|
||||
|
||||
if "$set" in ops:
|
||||
for k, v in ops["$set"].items():
|
||||
existing[k] = v
|
||||
if "$inc" in ops:
|
||||
for k, v in ops["$inc"].items():
|
||||
existing[k] = (existing.get(k) or 0) + v
|
||||
return FakeJobsCollection.UpdateResult(matched_count=1, modified_count=1)
|
||||
|
||||
|
||||
class FakeDb:
|
||||
def __init__(self, jobs: FakeJobsCollection | None = None) -> None:
|
||||
self._jobs = jobs or FakeJobsCollection()
|
||||
|
||||
def __getitem__(self, col: str):
|
||||
if col == "jobs":
|
||||
return self._jobs
|
||||
raise KeyError(col)
|
||||
|
||||
|
||||
class FakeSettings:
|
||||
mongo_uri = "mongodb://fake"
|
||||
mongo_db = "fake_asf"
|
||||
rabbitmq_uri = "amqp://fake"
|
||||
queues: list[str] = []
|
||||
prefetch_count: int = 1
|
||||
worker_id: str = "test-worker"
|
||||
enable_ai_handlers: bool = False
|
||||
ai_gateway_provider: str = "mock"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_settings():
|
||||
return FakeSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_jobs_collection():
|
||||
return FakeJobsCollection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db(fake_jobs_collection):
|
||||
return FakeDb(jobs=fake_jobs_collection)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_deps(fake_settings, fake_db, fake_jobs_collection):
|
||||
from app.handlers import WorkerDependencies
|
||||
return WorkerDependencies(
|
||||
db=fake_db,
|
||||
jobs=fake_jobs_collection,
|
||||
ai_executions=fake_db["jobs"], # fine - not exercised
|
||||
artifacts=fake_db["jobs"],
|
||||
settings=fake_settings,
|
||||
worker_id=fake_settings.worker_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_envelope():
|
||||
from asf_events.base import JobRequestEnvelope
|
||||
from asf_domain import JobType
|
||||
return JobRequestEnvelope(
|
||||
job_id=uuid4(),
|
||||
tenant_id=TENANT_ID,
|
||||
project_id=PROJECT_ID,
|
||||
correlation_id=CORRELATION_ID,
|
||||
type=JobType.DEVOPS.value,
|
||||
payload={"hello": "world", "sequence": 7},
|
||||
priority="normal",
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for worker handlers + claim/persistence logic.
|
||||
|
||||
All tests run in-memory using FakeJobsCollection — no Mongo/Rabbit required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from asf_domain import JobStatus, JobUsage
|
||||
|
||||
|
||||
class TestEchoHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_echo_handler_returns_payload_as_result(self, worker_deps, sample_envelope):
|
||||
from app.handlers import echo_handler
|
||||
res = await echo_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.COMPLETED
|
||||
assert res.result["echo"] == sample_envelope.payload
|
||||
assert res.result["handled_by"] == worker_deps.worker_id
|
||||
assert isinstance(res.usage, JobUsage)
|
||||
assert res.usage.cpu_seconds > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_handler_marks_failed_when_queue_unregistered(self, worker_deps, sample_envelope):
|
||||
from app.handlers import noop_handler
|
||||
res = await noop_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.FAILED
|
||||
assert "No worker handler registered" in res.error
|
||||
assert res.error_detail is not None and "hint" in res.error_detail
|
||||
|
||||
|
||||
class TestMarkJobDonePersistence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_job_done_writes_status_result_usage(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.handlers import mark_job_done, HandlerResult
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"status": JobStatus.RUNNING.value,
|
||||
}
|
||||
result = HandlerResult(
|
||||
status=JobStatus.COMPLETED,
|
||||
result={"echo": {"ok": True}},
|
||||
usage=JobUsage(cpu_seconds=0.042, input_tokens=100, output_tokens=200),
|
||||
)
|
||||
await mark_job_done(worker_deps, sample_envelope.job_id, result)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.COMPLETED.value
|
||||
assert doc["result"] == result.result
|
||||
assert doc["worker"] == worker_deps.worker_id
|
||||
assert doc["usage"]["cpu_seconds"] == 0.042
|
||||
assert doc["usage"]["input_tokens"] == 100
|
||||
assert doc["completed_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_job_done_records_error_when_failed(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.handlers import mark_job_done, HandlerResult
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {"_id": sample_envelope.job_id}
|
||||
await mark_job_done(
|
||||
worker_deps,
|
||||
sample_envelope.job_id,
|
||||
HandlerResult(
|
||||
status=JobStatus.FAILED,
|
||||
error="BOOM",
|
||||
error_detail={"stage": "analyze"},
|
||||
),
|
||||
)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.FAILED.value
|
||||
assert doc["error"] == "BOOM"
|
||||
assert doc["error_detail"] == {"stage": "analyze"}
|
||||
# result / usage keys are ABSENT when empty
|
||||
assert "result" not in doc
|
||||
|
||||
|
||||
class TestClaimRunningAtomic:
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_sets_running_and_inc_retries(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.worker import _claim_running
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"tenant_id": sample_envelope.tenant_id,
|
||||
"status": JobStatus.QUEUED.value,
|
||||
"retries": 0,
|
||||
}
|
||||
await _claim_running(worker_deps, sample_envelope)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
assert doc["status"] == JobStatus.RUNNING.value
|
||||
assert doc["worker"] == worker_deps.worker_id
|
||||
assert doc["retries"] == 1
|
||||
assert doc["started_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_bails_if_job_already_terminal(self, worker_deps, sample_envelope, fake_jobs_collection):
|
||||
from app.worker import _claim_running
|
||||
fake_jobs_collection._docs[sample_envelope.job_id] = {
|
||||
"_id": sample_envelope.job_id,
|
||||
"tenant_id": sample_envelope.tenant_id,
|
||||
"status": JobStatus.COMPLETED.value, # not QUEUED
|
||||
"worker": "some-other-worker",
|
||||
}
|
||||
await _claim_running(worker_deps, sample_envelope)
|
||||
doc = fake_jobs_collection._docs[sample_envelope.job_id]
|
||||
# still terminal + still original worker -> no overwrites
|
||||
assert doc["status"] == JobStatus.COMPLETED.value
|
||||
assert doc["worker"] == "some-other-worker"
|
||||
assert "retries" not in doc or doc["retries"] == 0
|
||||
|
||||
|
||||
class TestAIMockedGatewayHandler:
|
||||
"""Ensure ai_handler works safely with no real AI gateway attached (fallback path)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_handler_without_gateway_returns_payload_note(self, worker_deps, sample_envelope):
|
||||
from asf_domain import JobType
|
||||
sample_envelope.type = JobType.AI_INTERACTIVE.value
|
||||
sample_envelope.payload = {"kind": "ask_asf", "question": "Summarize REQ-103"}
|
||||
worker_deps.ai_gateway = None
|
||||
from app.handlers import ai_handler
|
||||
res = await ai_handler(sample_envelope, worker_deps)
|
||||
assert res.status == JobStatus.COMPLETED
|
||||
assert "ai_gateway not attached" in res.result["note"]
|
||||
assert res.result["payload"]["kind"] == "ask_asf"
|
||||
|
||||
|
||||
class TestHandlersQueueContract:
|
||||
def test_expected_queues_have_registered_handlers(self):
|
||||
from app.worker import HANDLERS_BY_QUEUE
|
||||
expected = {
|
||||
"asf.ai.interactive",
|
||||
"asf.ai.background",
|
||||
"asf.indexing",
|
||||
"asf.code_analysis",
|
||||
"asf.testing",
|
||||
"asf.devops",
|
||||
"asf.notifications",
|
||||
}
|
||||
assert set(HANDLERS_BY_QUEUE.keys()) == expected
|
||||
Reference in New Issue
Block a user