"""Top-level test fixtures. Provides a Postgres-backed `db_session` and a synthetic `smoke_test_batch` fixture that seeds the minimum DB state needed by the Phase 4 tracing- completeness property test. Why Postgres and not SQLite: The production schema uses Postgres-only types — `UUID(as_uuid=True)`, `ARRAY(String)`, `ARRAY(UUID(as_uuid=True))` — across many tables. SQLite cannot compile those via `Base.metadata.create_all`, and migration 006_gateway_columns adds `acceptance_criteria_status` / `qa_evidence_inspected` as JSON+Boolean columns that the current ORM model in `roboco/db/tables.py` does NOT yet map (a pre-existing layer drift). The honest path is to spin up a real Postgres test database, build the schema via `Base.metadata.create_all` (sidestepping pre-existing alembic drift in 001's enum casing and 008's nonexistent `agents.skills` column), then apply the additive columns from migration 006 manually so the test reads the same DB-level contract that the live services do. Behaviour: Tests that request `db_session` or `smoke_test_batch` are skipped at collection time when no Postgres is reachable on `localhost:5432`. Set `ROBOCO_TEST_DB_HOST`, `ROBOCO_TEST_DB_PORT`, `ROBOCO_TEST_DB_USER`, or `ROBOCO_TEST_DB_PASSWORD` to override. """ from __future__ import annotations import json import os import socket from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 import asyncpg import pytest import pytest_asyncio from roboco.db import tables as roboco_tables from roboco.db.base import Base from roboco.db.tables import ( AgentTable, AuditLogTable, JournalEntryTable, JournalTable, ProjectTable, TaskTable, ) from roboco.models.base import ( AgentRole, AgentStatus, Complexity, JournalEntryType, TaskNature, TaskStatus, TaskType, Team, ) from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, create_async_engine, ) if TYPE_CHECKING: from collections.abc import AsyncIterator # --------------------------------------------------------------------------- # Test DB endpoint discovery — env-overridable, default to localhost:5432. # --------------------------------------------------------------------------- _TEST_DB_HOST = os.environ.get("ROBOCO_TEST_DB_HOST", "localhost") _TEST_DB_PORT = int(os.environ.get("ROBOCO_TEST_DB_PORT", "5432")) _TEST_DB_USER = os.environ.get("ROBOCO_TEST_DB_USER", os.environ.get("USER", "renzof")) _TEST_DB_PASSWORD = os.environ.get("ROBOCO_TEST_DB_PASSWORD", "") _TEST_DB_ADMIN_DB = os.environ.get("ROBOCO_TEST_DB_ADMIN_DB", "postgres") def _postgres_reachable() -> bool: """Check that we can open a TCP connection to the configured Postgres.""" try: with socket.create_connection((_TEST_DB_HOST, _TEST_DB_PORT), timeout=1.0): return True except OSError: return False _PG_AVAILABLE = _postgres_reachable() # Sanity-check imported tables registered themselves on Base.metadata. Tied to # `roboco_tables` so static analysis treats the import as load-bearing. if not hasattr(roboco_tables, "TaskTable"): raise RuntimeError("roboco.db.tables failed to register TaskTable on Base") def _build_url(database: str) -> str: auth = _TEST_DB_USER if _TEST_DB_PASSWORD: auth = f"{_TEST_DB_USER}:{_TEST_DB_PASSWORD}" return f"postgresql+asyncpg://{auth}@{_TEST_DB_HOST}:{_TEST_DB_PORT}/{database}" # --------------------------------------------------------------------------- # Session-scoped: provision an ephemeral test DB and build the schema. # --------------------------------------------------------------------------- @pytest_asyncio.fixture(scope="session", loop_scope="session") async def _test_database_url() -> AsyncIterator[str]: """Create a fresh `roboco_test__` DB, build the schema, yield URL. Drops the database on teardown. Skips the entire test session if Postgres is unreachable. """ if not _PG_AVAILABLE: pytest.skip( f"Postgres unreachable at {_TEST_DB_HOST}:{_TEST_DB_PORT} — " "set ROBOCO_TEST_DB_HOST/PORT/USER/PASSWORD or start Postgres", allow_module_level=False, ) db_name = f"roboco_test_{os.getpid()}_{uuid4().hex[:8]}" # 1. CREATE DATABASE on the admin connection. admin = await asyncpg.connect( host=_TEST_DB_HOST, port=_TEST_DB_PORT, user=_TEST_DB_USER, password=_TEST_DB_PASSWORD or None, database=_TEST_DB_ADMIN_DB, ) try: await admin.execute(f'CREATE DATABASE "{db_name}"') finally: await admin.close() test_url_async = _build_url(db_name) # 2. Enable pgvector extension (some tables created via create_all reference # it; harmless if already enabled in the cluster). pgvector_engine = create_async_engine(test_url_async, future=True) try: async with pgvector_engine.begin() as conn: await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) finally: await pgvector_engine.dispose() # 3. Build the schema. We use `Base.metadata.create_all` (NOT alembic # upgrade head) for two reasons: # # a. Migration 001 declares the `agentrole` Postgres enum with # lowercase string values (`"qa"`, `"developer"`, ...), but the # SQLAlchemy ORM binds `Enum(AgentRole)` to the StrEnum's member # NAMES (uppercase: `"QA"`, `"DEVELOPER"`, ...) — they're # mismatched. Production DBs were bootstrapped via `create_all` # (which generates uppercase enums to match the ORM) and then # stamped at 001, masking this drift. # # b. Migration 008 runs `UPDATE agents SET skills = ... WHERE id = ...` # against an `agents.skills` column that no migration in this chain # ever creates — another pre-existing drift item. # # Neither bug is caused by this PR; both block any "fresh alembic # upgrade head" run today. `create_all` sidesteps both. After # `create_all` we manually apply the *additive* columns from migration # 006 (`acceptance_criteria_status`, `qa_evidence_inspected`) since # those are the columns the tracing-completeness contract reads and # they are NOT yet on the ORM TaskTable. schema_engine = create_async_engine(test_url_async, future=True) try: async with schema_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) await conn.execute( text( "ALTER TABLE tasks " "ADD COLUMN IF NOT EXISTS acceptance_criteria_status " "JSON NOT NULL DEFAULT '[]'::json" ) ) await conn.execute( text( "ALTER TABLE tasks " "ADD COLUMN IF NOT EXISTS qa_evidence_inspected " "BOOLEAN NOT NULL DEFAULT FALSE" ) ) finally: await schema_engine.dispose() try: yield test_url_async finally: # 4. Drop the test database on teardown. teardown_admin = await asyncpg.connect( host=_TEST_DB_HOST, port=_TEST_DB_PORT, user=_TEST_DB_USER, password=_TEST_DB_PASSWORD or None, database=_TEST_DB_ADMIN_DB, ) try: # Force-disconnect any straggler sessions before DROP. await teardown_admin.execute( "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid()" ) await teardown_admin.execute(f'DROP DATABASE IF EXISTS "{db_name}"') finally: await teardown_admin.close() @pytest_asyncio.fixture async def db_session(_test_database_url: str) -> AsyncIterator[AsyncSession]: """Yield an `AsyncSession` bound to the migrated test DB. Each test gets its own session and connection; teardown rolls back any uncommitted state and disposes the engine to keep connection counts low. """ engine = create_async_engine(_test_database_url, future=True, pool_pre_ping=True) factory = async_sessionmaker( bind=engine, class_=AsyncSession, expire_on_commit=False ) async with factory() as session: try: yield session finally: await session.rollback() await engine.dispose() # --------------------------------------------------------------------------- # Synthetic seed: 1 project + 3 agents + 1 completed task with full tracing. # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def smoke_test_batch(db_session: AsyncSession) -> AsyncIterator[list[UUID]]: """Seed a single completed task that satisfies all 6 tracing-contract bullets. Returns the list of completed task IDs (length 1). The test iterates this list and asserts the contract on each — the same shape Phase 4 Task 12's real smoke flow will produce on the NAS. Seeded rows: * 1 ProjectTable (assigned_cell=BACKEND) * 1 system AgentTable (creator-of-record) * 3 AgentTables: dev (DEVELOPER), qa (QA), pm (CELL_PM) — all BACKEND * 1 TaskTable: status=COMPLETED, with 2 acceptance_criteria * 3 AuditLogTable rows: dev claim, qa pass, pm complete (target_id=task.id) * 3 JournalTables (one per dev/qa/pm) + 3 JournalEntryTable rows: - dev: TASK_REFLECTION - qa: LEARNING - pm: DECISION_LOG * Raw-SQL UPDATE on `tasks.acceptance_criteria_status` and `tasks.qa_evidence_inspected` (these columns exist in the migrated DB but are NOT mapped on the ORM TaskTable — pre-existing drift). """ # 1. Creator-of-record system agent (FK target for project.created_by etc.). system_agent = AgentTable( id=uuid4(), name="System", slug=f"system-{uuid4().hex[:8]}", role=AgentRole.SYSTEM, team=None, status=AgentStatus.ACTIVE, model_config={}, system_prompt="system", capabilities=[], permissions={}, metrics={}, ) db_session.add(system_agent) await db_session.flush() # 2. Project. project = ProjectTable( id=uuid4(), name="Smoke Test Project", slug=f"smoke-{uuid4().hex[:8]}", git_url="https://github.com/example/smoke.git", default_branch="main", protected_branches=["main"], assigned_cell=Team.BACKEND, created_by=system_agent.id, is_active=True, ) db_session.add(project) await db_session.flush() # 3. Three workforce agents — dev, qa, cell_pm — all on the backend cell. dev_agent = AgentTable( id=uuid4(), name="Backend Dev 1", slug=f"be-dev-1-{uuid4().hex[:8]}", role=AgentRole.DEVELOPER, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="dev", capabilities=["python"], permissions={}, metrics={}, ) qa_agent = AgentTable( id=uuid4(), name="Backend QA", slug=f"be-qa-{uuid4().hex[:8]}", role=AgentRole.QA, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="qa", capabilities=["review"], permissions={}, metrics={}, ) pm_agent = AgentTable( id=uuid4(), name="Backend Cell PM", slug=f"be-pm-{uuid4().hex[:8]}", role=AgentRole.CELL_PM, team=Team.BACKEND, status=AgentStatus.ACTIVE, model_config={}, system_prompt="pm", capabilities=["review"], permissions={}, metrics={}, ) db_session.add_all([dev_agent, qa_agent, pm_agent]) await db_session.flush() # 4. Task — status=COMPLETED, with two acceptance criteria. criteria = [ "API endpoint returns 200 for the happy path", "Failure mode returns structured error envelope", ] task = TaskTable( id=uuid4(), title="Add /api/v2/example endpoint", description="Smoke task — synthetic happy path.", acceptance_criteria=criteria, status=TaskStatus.COMPLETED, priority=2, task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, project_id=project.id, branch_name="feature/backend/SMOKE001", pr_number=1, pr_url="https://github.com/example/smoke/pull/1", docs_complete=True, pr_created=True, pm_approvals={"cell_pm": str(pm_agent.id)}, created_by=system_agent.id, assigned_to=dev_agent.id, team=Team.BACKEND, dependency_ids=[], blocker_ids=[], sequence=0, plan={"steps": ["draft", "implement", "test"]}, estimated_complexity=Complexity.MEDIUM, execution_log={}, checkpoints=[], progress_updates=[{"at": "t0", "note": "started"}], commits=[{"sha": "abc123", "message": "[SMOKE001] initial"}], documents=[], outputs=[], dev_notes="Built as planned.", qa_notes=( "Reviewed PR #1 carefully. Branch convention correct. Commit " "message includes task ID. Diff matches spec. No security issues." ), self_verified=True, qa_verified=True, ) db_session.add(task) await db_session.flush() # 4b. Migration-only columns the ORM doesn't map: acceptance_criteria_status # + qa_evidence_inspected. Set them via raw SQL so the test asserts the # real DB-level contract. artifact_id = str(uuid4()) status_payload: list[dict[str, Any]] = [ {"criterion": criteria[0], "referencing_artifact_id": artifact_id}, {"criterion": criteria[1], "referencing_artifact_id": artifact_id}, ] await db_session.execute( text( "UPDATE tasks " "SET acceptance_criteria_status = CAST(:status AS json), " " qa_evidence_inspected = TRUE " "WHERE id = :tid" ), {"status": json.dumps(status_payload), "tid": task.id}, ) # 5. Three audit-log rows — one per state transition, ALL with non-null # agent_id targeting this task's id. audit_rows = [ AuditLogTable( id=uuid4(), event_type="task.claimed", agent_id=dev_agent.id, target_type="task", target_id=task.id, severity="info", details={"from": "pending", "to": "claimed"}, ), AuditLogTable( id=uuid4(), event_type="task.qa_pass", agent_id=qa_agent.id, target_type="task", target_id=task.id, severity="info", details={"from": "awaiting_qa", "to": "awaiting_documentation"}, ), AuditLogTable( id=uuid4(), event_type="task.completed", agent_id=pm_agent.id, target_type="task", target_id=task.id, severity="info", details={"from": "awaiting_pm_review", "to": "completed"}, ), ] db_session.add_all(audit_rows) await db_session.flush() # 6. Journals + entries: dev:TASK_REFLECTION, qa:LEARNING, pm:DECISION_LOG. dev_journal = JournalTable(id=uuid4(), agent_id=dev_agent.id) qa_journal = JournalTable(id=uuid4(), agent_id=qa_agent.id) pm_journal = JournalTable(id=uuid4(), agent_id=pm_agent.id) db_session.add_all([dev_journal, qa_journal, pm_journal]) await db_session.flush() db_session.add_all( [ JournalEntryTable( id=uuid4(), journal_id=dev_journal.id, type=JournalEntryType.TASK_REFLECTION, title="Reflection on SMOKE001", content="Implemented as planned; happy-path commits clean.", task_id=task.id, tags=["reflect"], ), JournalEntryTable( id=uuid4(), journal_id=qa_journal.id, type=JournalEntryType.LEARNING, title="Learning from SMOKE001 review", content="QA: confirmed acceptance criteria addressed by artifact.", task_id=task.id, tags=["learning"], ), JournalEntryTable( id=uuid4(), journal_id=pm_journal.id, type=JournalEntryType.DECISION_LOG, title="Decision: merge SMOKE001", content="PM approval recorded; PR merged into target branch.", task_id=task.id, tags=["decision"], ), ] ) await db_session.commit() # `TaskTable.id` is annotated `Mapped[UUID]` where `UUID` is the SA dialect # UUID type (not `uuid.UUID`) — a pre-existing typing oversight in the ORM. # The runtime value IS a `uuid.UUID`; convert through `str()` so callers # (and mypy) see the right type without smuggling in a type-ignore comment. seeded_task_ids: list[UUID] = [UUID(str(task.id))] yield seeded_task_ids # --------------------------------------------------------------------------- # Skip Postgres-dependent tests when the test DB endpoint is unreachable. # --------------------------------------------------------------------------- def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item] ) -> None: """Mark Postgres-dependent tests as skipped if Postgres is unreachable. Tests that do NOT request `db_session` or `smoke_test_batch` are unaffected. """ _ = config if _PG_AVAILABLE: return skip = pytest.mark.skip( reason=( f"Postgres unreachable at {_TEST_DB_HOST}:{_TEST_DB_PORT} — " "set ROBOCO_TEST_DB_HOST/PORT/USER/PASSWORD or start Postgres" ) ) fixtures_requiring_db = {"db_session", "smoke_test_batch"} for item in items: fixturenames = set(getattr(item, "fixturenames", ())) if fixturenames & fixtures_requiring_db: item.add_marker(skip)