Files
roboco/tests/conftest.py
T
1c87a4e4e4 Leak fixes, gate green again, uv/CI hardening, e2e lifecycle smoke harness (#294)
* test: align phase1 smoke mock with the armed team-match gate

The 8e5f84c4 sweep fixed 13 test files' inconsistent-team mocks but ran
only the gateway/foundation/runtime subsets; the full gate caught this
integration mock whose parent task carried an auto-generated MagicMock
team and died on not_authorized before the incomplete_input assertion.

* fix(runtime): attribute every agent.spawned audit to its dispatcher

A rogue spawner could not be identified live (2026-07-02): agent.spawned
rows carry container/model but not which dispatch loop launched them.
spawn_agent now takes spawned_by, stamps it into the spawned/spawn_failed
audit details, every call site passes its loop name, and an AST sweep
test holds future callers to it.

* fix(api): admin-complete refuses when the task's PR is still open

PATCH status=completed on a task with an OPEN PR stranded its commits
unmerged (bit the CEO twice live 2026-07-02). The override now refuses
with the PR number/URL and the consequence before the generic hatch
text; force:true stays the deliberate, audited escape.

* fix(panel): awaiting_ceo_approval offers the working ceo-approve path

The header's only approve action was Approve & Merge (POST
/approve-and-merge, no notes) which 400s NO_PR on a branchless MegaTask
umbrella — the CEO's approve button just failed. Primary action is now
Approve & Complete via the CeoApproveDialog (POST /ceo-approve, notes
>=20 chars, proven live); Approve & Merge stays for PR-bearing tasks.

* test: stop leaking self-heal + rate-limit state into live Redis

Two test files wrote real keys into a developer's localhost Redis:
self-heal originate tests left self_heal:notified:* (2h TTL) and the
i_am_blocked rate-limited tests left a NO-TTL 'anthropic rate-limited'
tracker blob — order/state-dependent poison for anything reading the
real tracker, and the prime suspect class for the one-off
test_self_heal_engine full-run failure (not reproduced in 5x dir runs,
adversarial orders, and a green full gate). Both files now point the
computed redis_url at an unreachable port; the engines' fail-open paths
keep every assertion intact. Leaked keys scrubbed live.

* docs: changelog + map delta for the leak-fix batch; mypy-clean attribution test

The attribution test's direct method assignments tripped the full gate's
mypy (method-assign) — switched to the house monkeypatch idiom, no
suppressions.

* fix(gate): clear the ten xenon C-ranks; isolate all tests from live Redis

Master CI has been red at the phase1 smoke test, so neither CI nor a
local full gate had reached the xenon step since the team-match sweep —
whose inline 'agent_team=str(agent.team) if ...' kwarg pushed nine verb
bodies from B(10) to C(11-12) unseen. A shared actor_context_fields()
(_protocol.py) computes (actor_slug, agent_team) once per verb, restoring
all nine to B with zero behavior change; the new admin-complete override
helper extraction does the same for routes/tasks.py.

tests/conftest.py gains an autouse fixture pointing the computed
redis_url at an unreachable port for every test — the root fix for the
three families caught writing live-Redis keys (self-heal dedupe,
rate-limit tracker, notification purpose-dedupe); no test uses a real
Redis, and every production path is fail-open by design.

* refactor(runtime): delete the never-wired dispatch-time spawn cooldown

_safe_spawn / gateway_pre_spawn_check / trigger_filter had no caller in
the repo's entire history (87ef42bf only flipped the flag). Its five
rules are superseded: provider parking runs inside spawn_agent, claim
freshness is the guards+reaper, runaway respawns are the progress-aware
breaker + notification cooldown; the per-task cooldown rule would
queue-stall every normal stage handoff if wired today. gateway_triggers
table kept inert. Ratified by the CEO over wiring it.

* build: serialize uv — gate recipes never implicitly sync the venv

Every uv run re-syncs implicitly, so a background make quality plus any
foreground uv run raced two writers on one .venv and tore site-packages
apart (the recurring rich/pip/bandit ImportError corruption; bit twice
today, four times on 2026-07-02's first session). UV_NO_SYNC=1 is now
exported Makefile-wide and quality/quality-fast/gate depend on one
explicit up-front sync step.

* fix(git): PR/merge/branch REST calls honor github_api_base_url

Fifteen sites hardcoded https://api.github.com while the CI-run and
open-PR-list calls already read settings.github_api_base_url — a GHE or
test override silently applied to half the surface. One _api_base()
helper keeps them uniform; default behavior unchanged.

* ci: split the monolith — backend CI, Panel CI, E2E Smoke

ci.yml keeps its file name and the backend quality job only (self-heal /
ci-watch / release-readiness default to the ci.yml workflow); the panel
job moves to panel-ci.yml scoped to panel/**, and the new scripted-agent
lifecycle smoke gets e2e-smoke.yml + a make e2e-smoke target (env-gated
out of the default pytest run). Trade: a panel-only red now lands on
Panel CI, which the ci.yml-pinned watch engines don't see.

* feat(tests): e2e lifecycle smoke harness — scripted agents, real gates

tests/e2e_smoke stands up the real API (flow/do routers + middleware on
uvicorn) over the ephemeral test Postgres, a local bare origin standing
in for GitHub, and a fake GitHub REST layer whose merges are real git
merges. A deterministic driver reloads the real MCP flow/do modules per
agent and walks claim (real clone + worktree) -> tracing-gap -> note ->
plan gate -> commit -> PR -> the full i_am_done ladder -> QA verdicts ->
documenter -> awaiting_pm_review in ~5s. Runs via make e2e-smoke + its
own CI workflow; skipped (env-gated) in the default suite. The
freeze-lift condition's first half: scenario 1 green.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-02 18:28:07 +02:00

549 lines
21 KiB
Python

"""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.
Redis isolation:
No test uses a real Redis, so `_no_live_redis` (autouse) points the
computed `settings.redis_url` at an unreachable port for every test.
Three test families were caught writing real keys into whatever Redis
listens on localhost:6379 (self-heal notify-dedupe, the rate-limit
tracker's NO-TTL "provider rate-limited" blob, notification
purpose-dedupe) — order/state poison for anything reading the real
instance. Every production Redis path is fail-open by design, so an
unreachable port keeps behavior identical to "no redis available".
"""
from __future__ import annotations
import json
import os
import socket
import warnings
from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4
import asyncpg
import pytest
import pytest_asyncio
from roboco.config import settings as _settings
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
@pytest.fixture(autouse=True)
def _no_live_redis(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep every test off the real localhost Redis (see module docstring).
``settings.redis_url`` is a computed property, so its inputs are patched.
Port 1 refuses instantly — fail-open code paths behave exactly as with no
Redis, and nothing can read or write live keys.
"""
monkeypatch.setattr(_settings, "redis_host", "127.0.0.1")
monkeypatch.setattr(_settings, "redis_port", 1)
# ---------------------------------------------------------------------------
# Test DB endpoint discovery — env-overridable.
#
# Defaults match the project's own running postgres (`roboco-postgres` in
# `docker-compose.yml`): superuser `roboco`/`roboco`, host-exposed on
# `localhost:15432` (the container's 5432). `roboco` has CREATEDB, which the
# session fixture needs to provision/drop an ephemeral per-run test DB.
#
# Previously these defaulted to the OS `$USER` with an empty password on
# `localhost:5432`, which hit a bare system postgres that has no such role —
# every `db_session` test failed with `InvalidPasswordError: password
# authentication failed for user "renzof"` instead of running. Defaulting to
# the project's actual DB makes the integration suite run out of the box; any
# of these can still be overridden with `ROBOCO_TEST_DB_*`.
# ---------------------------------------------------------------------------
_TEST_DB_HOST = os.environ.get("ROBOCO_TEST_DB_HOST", "localhost")
_TEST_DB_PORT = int(os.environ.get("ROBOCO_TEST_DB_PORT", "15432"))
_TEST_DB_USER = os.environ.get("ROBOCO_TEST_DB_USER", "roboco")
_TEST_DB_PASSWORD = os.environ.get("ROBOCO_TEST_DB_PASSWORD", "roboco")
_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
def _warn_if_pg_unavailable(available: bool, host: str, port: int) -> None:
"""#90: warn loudly when the test DB is unreachable so a green run of all-skips
is not mistaken for a pass. The per-test ``pytest.skip`` still applies; this
only adds a visible import-time warning."""
if not available:
warnings.warn(
f"Postgres unreachable at {host}:{port} — every DB test will be "
"SKIPPED. Set ROBOCO_TEST_DB_HOST/PORT/USER/PASSWORD or start "
"Postgres (local: ROBOCO_TEST_DB_PORT=55432 ROBOCO_TEST_DB_USER=renzof).",
stacklevel=2,
)
_PG_AVAILABLE = _postgres_reachable()
_warn_if_pg_unavailable(_PG_AVAILABLE, _TEST_DB_HOST, _TEST_DB_PORT)
# 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_<pid>_<rand>` 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.
"""
# No pool_pre_ping: a fresh per-test engine can't have stale connections, and
# pre-ping on asyncpg leaves an un-awaited Connection._cancel coroutine that
# surfaces as a RuntimeWarning during GC.
engine = create_async_engine(_test_database_url, future=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/v1/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,
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,
checkpoints=[],
progress_updates=[{"at": "t0", "note": "started"}],
commits=[{"sha": "abc123", "message": "[SMOKE001] initial"}],
documents=[],
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)