from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from central.app.jobs import claim_next_job, complete_job, create_job, fail_job from central.app.main import create_app from central.app.models import Agent, Base, BlockedProduct, Installation, Job, Tenant def _make_agent(session): tenant = Tenant(name="development") session.add(tenant); session.flush() agent = Agent(tenant_id=tenant.id, name="agent-a", token_hash="hash-a") other = Agent(tenant_id=tenant.id, name="agent-b", token_hash="hash-b") session.add_all([agent, other]); session.commit() return agent, other def test_claim_is_scoped_to_owning_agent_and_marks_running(): engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as session: agent, other = _make_agent(session) job = create_job(session, agent, "REPROCESS_ALL") assert claim_next_job(session, other) is None # other agent never sees it claimed = claim_next_job(session, agent) assert claimed.id == job.id assert claimed.status == "RUNNING" assert claimed.attempts == 1 def test_claim_is_not_reentrant_once_running(): engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as session: agent, _ = _make_agent(session) create_job(session, agent, "REPROCESS_ALL") first = claim_next_job(session, agent) second = claim_next_job(session, agent) # repeated polling must not duplicate consumption assert first is not None assert second is None def test_completed_job_can_never_run_again(): engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as session: agent, _ = _make_agent(session) create_job(session, agent, "REPROCESS_ALL") job = claim_next_job(session, agent) assert complete_job(session, job, {"writes": 0}) is True assert session.get(Job, job.id).status == "COMPLETED" assert claim_next_job(session, agent) is None assert complete_job(session, job, {"writes": 1}) is False # already terminal def test_failed_job_is_terminal_with_sanitized_error(): engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as session: agent, _ = _make_agent(session) create_job(session, agent, "REPROCESS_ALL") job = claim_next_job(session, agent) assert fail_job(session, job, "boom " * 1000) is True stored = session.get(Job, job.id) assert stored.status == "FAILED" assert len(stored.error) <= 2000 def _client(): engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(engine) app = create_app(session_factory=lambda: Session(engine), bootstrap_token="test-token") return TestClient(app), engine def test_job_api_end_to_end_flow(): client, engine = _client() headers = {"Authorization": "Bearer test-token"} heartbeat = client.post("/v1/agents/heartbeat", json={"status": "IDLE", "schema_fingerprint": "abc", "agent_version": "0.1.0"}, headers=headers) assert heartbeat.status_code == 200 assert heartbeat.json()["enabled"] is True created = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {"codigos": ["000026"]}}, headers=headers) assert created.status_code == 200 job_id = created.json()["job_id"] assert created.json()["status"] == "PENDING" claimed = client.get("/v1/jobs/next", headers=headers) assert claimed.json()["job_id"] == job_id assert claimed.json()["status"] == "RUNNING" empty = client.get("/v1/jobs/next", headers=headers) assert empty.json() is None # no more pending work; not the same job again completed = client.post(f"/v1/jobs/{job_id}/complete", json={"result_summary": {"writes_succeeded": 1}}, headers=headers) assert completed.json()["status"] == "COMPLETED" assert completed.json()["result_summary"]["writes_succeeded"] == 1 fetched = client.get(f"/v1/jobs/{job_id}", headers=headers) assert fetched.json()["status"] == "COMPLETED" def test_agent_status_derives_online_offline(): client, engine = _client() headers = {"Authorization": "Bearer test-token"} offline = client.get("/v1/agents/me", headers=headers) assert offline.json()["status"] == "OFFLINE" # never heartbeated yet client.post("/v1/agents/heartbeat", json={"status": "IDLE"}, headers=headers) online = client.get("/v1/agents/me", headers=headers) body = online.json() assert body["status"] == "ONLINE" assert body["enabled"] is True assert "company_id" in body def test_agent_identity_endpoint_returns_existing_installation_identity_only(): client, _ = _client() headers = {"Authorization": "Bearer test-token"} installation_id = "00000000-0000-4000-8000-000000000222" heartbeat = client.post( "/v1/agents/heartbeat", json={ "installation_id": installation_id, "hostname": "DESKTOP-B0HL02I", "agent_version": "1.1.1", "status": "IDLE", }, headers=headers, ) assert heartbeat.status_code == 200 response = client.get("/v1/agents/me/identity", headers=headers) assert response.status_code == 200 assert response.json() == { "agent_id": heartbeat.json()["agent_id"], "installation_id": installation_id, "hostname": "DESKTOP-B0HL02I", "version": "1.1.1", "status": "ONLINE", } def test_agent_identity_endpoint_returns_null_installation_without_creating_one(): client, engine = _client() headers = {"Authorization": "Bearer test-token"} response = client.get("/v1/agents/me/identity", headers=headers) assert response.status_code == 200 body = response.json() assert body == { "agent_id": body["agent_id"], "installation_id": None, "hostname": None, "version": None, "status": "OFFLINE", } assert isinstance(body["agent_id"], int) and body["agent_id"] > 0 with Session(engine) as session: assert session.query(Installation).count() == 0 def test_agent_identity_endpoint_requires_agent_authentication(): client, _ = _client() response = client.get("/v1/agents/me/identity") assert response.status_code == 401 def test_job_creation_rejects_unknown_kind(): client, _ = _client() headers = {"Authorization": "Bearer test-token"} response = client.post("/v1/jobs", json={"kind": "DELETE_EVERYTHING"}, headers=headers) assert response.status_code == 400 def test_reprocess_products_requires_codigos(): client, _ = _client() headers = {"Authorization": "Bearer test-token"} response = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {}}, headers=headers) assert response.status_code == 400 def test_blocker_registry_api_deduplicates_and_resolves(): client, engine = _client() headers = {"Authorization": "Bearer test-token"} assert client.post("/v1/agents/heartbeat", json={"status": "IDLE", "installation_id": "00000000-0000-4000-8000-000000000001"}, headers=headers).status_code == 200 summary = {"blocked_products": [{"company": "C", "product_code": "P1", "blocker_type": "BLOCKED_MISSING_EVIDENCE", "field": "CFOP", "reason_code": "MISSING", "input_fingerprint": "v1", "evidence_summary": {"source": "ERP"}}]} created = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {"codigos": ["P1"]}}, headers=headers) job_id = created.json()["job_id"] client.get("/v1/jobs/next", headers=headers) client.post(f"/v1/jobs/{job_id}/complete", json={"result_summary": summary}, headers=headers) listed = client.get("/v1/blockers?field=CFOP", headers=headers) assert listed.status_code == 200 assert len(listed.json()["blockers"]) == 1 with Session(engine) as session: assert session.query(BlockedProduct).one().installation_id is not None blocker_id = listed.json()["blockers"][0]["id"] resolved = client.post(f"/v1/blockers/{blocker_id}/resolve", json={"resolution_source": "TEST"}, headers=headers) assert resolved.json()["resolved"] is True assert client.get("/v1/blockers", headers=headers).json()["blockers"] == []