Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions agent/tests/test_connection_pool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""Tests for agent/db/connection.py — pool configuration logic without real Postgres."""

from unittest.mock import AsyncMock, patch

import pytest

import agent.db.connection as conn_mod
from agent.db.connection import _float_env, _int_env, close_pool, get_pool


@pytest.fixture(autouse=True)
def _reset_pool_globals():
"""Reset module-level pool state before and after every test."""
conn_mod._pool = None
conn_mod._pool_lock = None
yield
conn_mod._pool = None
conn_mod._pool_lock = None


# ---------------------------------------------------------------------------
# get_pool – DATABASE_URL validation
# ---------------------------------------------------------------------------


class TestGetPoolDatabaseUrl:
@pytest.mark.asyncio
async def test_raises_when_database_url_not_set(self, monkeypatch):
"""get_pool must raise RuntimeError when DATABASE_URL is empty."""
monkeypatch.delenv("DATABASE_URL", raising=False)
with pytest.raises(RuntimeError, match="DATABASE_URL environment variable is required"):
await get_pool()

@pytest.mark.asyncio
async def test_raises_when_database_url_empty_string(self, monkeypatch):
"""get_pool must raise RuntimeError when DATABASE_URL is an empty string."""
monkeypatch.setenv("DATABASE_URL", "")
with pytest.raises(RuntimeError, match="DATABASE_URL environment variable is required"):
await get_pool()


# ---------------------------------------------------------------------------
# _int_env / _float_env helpers
# ---------------------------------------------------------------------------


class TestIntEnv:
def test_returns_default_when_not_set(self, monkeypatch):
monkeypatch.delenv("TEST_INT_VAR", raising=False)
assert _int_env("TEST_INT_VAR", 42) == 42

def test_returns_parsed_value(self, monkeypatch):
monkeypatch.setenv("TEST_INT_VAR", "7")
assert _int_env("TEST_INT_VAR", 42) == 7

def test_clamps_to_minimum(self, monkeypatch):
monkeypatch.setenv("TEST_INT_VAR", "0")
assert _int_env("TEST_INT_VAR", 42, minimum=1) == 1

def test_returns_default_on_non_numeric(self, monkeypatch):
monkeypatch.setenv("TEST_INT_VAR", "abc")
assert _int_env("TEST_INT_VAR", 42) == 42


class TestFloatEnv:
def test_returns_default_when_not_set(self, monkeypatch):
monkeypatch.delenv("TEST_FLOAT_VAR", raising=False)
assert _float_env("TEST_FLOAT_VAR", 2.5) == 2.5

def test_returns_parsed_value(self, monkeypatch):
monkeypatch.setenv("TEST_FLOAT_VAR", "3.14")
assert _float_env("TEST_FLOAT_VAR", 2.5) == pytest.approx(3.14)

def test_clamps_to_minimum(self, monkeypatch):
monkeypatch.setenv("TEST_FLOAT_VAR", "-1.0")
assert _float_env("TEST_FLOAT_VAR", 2.5, minimum=0.0) == 0.0

def test_returns_default_on_non_numeric(self, monkeypatch):
monkeypatch.setenv("TEST_FLOAT_VAR", "nope")
assert _float_env("TEST_FLOAT_VAR", 2.5) == 2.5


# ---------------------------------------------------------------------------
# Pool size env var overrides
# ---------------------------------------------------------------------------


class TestPoolSizeOverrides:
@pytest.mark.asyncio
async def test_custom_pool_sizes_passed_to_create_pool(self, monkeypatch):
"""DB_POOL_MIN_SIZE and DB_POOL_MAX_SIZE should be forwarded to asyncpg.create_pool."""
monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb")
monkeypatch.setenv("DB_POOL_MIN_SIZE", "5")
monkeypatch.setenv("DB_POOL_MAX_SIZE", "20")

sentinel_pool = AsyncMock()
with patch(
"agent.db.connection.asyncpg.create_pool", new_callable=AsyncMock, return_value=sentinel_pool
) as mock_create:
pool = await get_pool()
mock_create.assert_awaited_once()
_, kwargs = mock_create.call_args
assert kwargs["min_size"] == 5
assert kwargs["max_size"] == 20
assert pool is sentinel_pool

@pytest.mark.asyncio
async def test_max_size_at_least_min_size(self, monkeypatch):
"""If DB_POOL_MAX_SIZE < DB_POOL_MIN_SIZE, max_size should be clamped to min_size."""
monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb")
monkeypatch.setenv("DB_POOL_MIN_SIZE", "10")
monkeypatch.setenv("DB_POOL_MAX_SIZE", "3")

sentinel_pool = AsyncMock()
with patch(
"agent.db.connection.asyncpg.create_pool", new_callable=AsyncMock, return_value=sentinel_pool
) as mock_create:
await get_pool()
_, kwargs = mock_create.call_args
assert kwargs["max_size"] >= kwargs["min_size"]


# ---------------------------------------------------------------------------
# close_pool
# ---------------------------------------------------------------------------


class TestClosePool:
@pytest.mark.asyncio
async def test_close_pool_resets_global(self):
"""close_pool should close the pool and set the global to None."""
fake_pool = AsyncMock()
conn_mod._pool = fake_pool

await close_pool()

fake_pool.close.assert_awaited_once()
assert conn_mod._pool is None

@pytest.mark.asyncio
async def test_close_pool_noop_when_none(self):
"""close_pool should be safe to call when no pool exists."""
assert conn_mod._pool is None
await close_pool() # should not raise
assert conn_mod._pool is None

@pytest.mark.asyncio
async def test_get_pool_after_close_recreates(self, monkeypatch):
"""After close_pool, a subsequent get_pool should create a fresh pool."""
monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/testdb")

first_pool = AsyncMock()
second_pool = AsyncMock()

with patch(
"agent.db.connection.asyncpg.create_pool",
new_callable=AsyncMock,
side_effect=[first_pool, second_pool],
):
pool1 = await get_pool()
assert pool1 is first_pool

await close_pool()
assert conn_mod._pool is None

pool2 = await get_pool()
assert pool2 is second_pool
assert pool2 is not pool1
127 changes: 127 additions & 0 deletions agent/tests/test_pipeline_runtime_score.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Tests for build_meeting_result score logic in pipeline_runtime.py.

Covers the fix where final_score=0 must be preserved and not overwritten by match_rate.
"""

from agent.pipeline_runtime import build_meeting_result


class TestBuildMeetingResultScore:
"""Score field in build_meeting_result must follow the fallback chain:
scoring.final_score -> code_eval_result.match_rate -> 0
"""

def test_final_score_zero_preserved(self):
"""final_score=0 must be preserved in the result, not overwritten by match_rate."""
state = {
"scoring": {"final_score": 0, "decision": "NO_GO"},
"code_eval_result": {"match_rate": 85},
}
result = build_meeting_result(state)
assert result["score"] == 0

def test_final_score_nonzero_used(self):
"""A non-zero final_score should appear as-is in the result."""
state = {
"scoring": {"final_score": 72, "decision": "GO"},
}
result = build_meeting_result(state)
assert result["score"] == 72

def test_no_final_score_falls_back_to_match_rate(self):
"""When final_score is absent (None), score should fall back to match_rate."""
state = {
"scoring": {"decision": "NO_GO"},
"code_eval_result": {"match_rate": 65},
}
result = build_meeting_result(state)
assert result["score"] == 65

def test_final_score_none_falls_back_to_match_rate(self):
"""When final_score is explicitly None, score should fall back to match_rate."""
state = {
"scoring": {"final_score": None, "decision": "NO_GO"},
"code_eval_result": {"match_rate": 50},
}
result = build_meeting_result(state)
assert result["score"] == 50

def test_no_final_score_no_match_rate_defaults_to_zero(self):
"""When neither final_score nor match_rate exist, score should be 0."""
state = {
"scoring": {"decision": "NO_GO"},
}
result = build_meeting_result(state)
assert result["score"] == 0

def test_empty_state_defaults_to_zero(self):
"""A completely empty state should produce score=0."""
result = build_meeting_result({})
assert result["score"] == 0

def test_match_rate_zero_used_as_fallback(self):
"""match_rate=0 is a valid numeric fallback (not None)."""
state = {
"scoring": {"decision": "NO_GO"},
"code_eval_result": {"match_rate": 0},
}
result = build_meeting_result(state)
assert result["score"] == 0

def test_match_rate_float_accepted(self):
"""match_rate as a float should be accepted as a valid score."""
state = {
"scoring": {"decision": "GO"},
"code_eval_result": {"match_rate": 92.5},
}
result = build_meeting_result(state)
assert result["score"] == 92.5

def test_code_eval_result_not_dict_ignored(self):
"""If code_eval_result is not a dict, match_rate fallback should not apply."""
state = {
"scoring": {"decision": "NO_GO"},
"code_eval_result": "invalid",
}
result = build_meeting_result(state)
assert result["score"] == 0

def test_match_rate_string_not_used(self):
"""A non-numeric match_rate should not be used as score (isinstance check)."""
state = {
"scoring": {"decision": "NO_GO"},
"code_eval_result": {"match_rate": "high"},
}
result = build_meeting_result(state)
assert result["score"] == 0


class TestBuildMeetingResultVerdict:
"""Verdict logic tests to complement the score tests."""

def test_go_verdict(self):
state = {"scoring": {"final_score": 80, "decision": "GO"}}
result = build_meeting_result(state)
assert result["verdict"] == "GO"

def test_nogo_verdict(self):
state = {"scoring": {"final_score": 30, "decision": "NO_GO"}}
result = build_meeting_result(state)
assert result["verdict"] == "NO-GO"

def test_conditional_verdict(self):
state = {"scoring": {"final_score": 55, "decision": "CONDITIONAL"}}
result = build_meeting_result(state)
assert result["verdict"] == "CONDITIONAL"

def test_pipeline_success_overrides_verdict_to_go(self):
state = {
"scoring": {"final_score": 30, "decision": "NO_GO"},
"code_eval_result": {"passed": True},
"build_validation": {"passed": True},
"local_runtime_validation": {"passed": True},
"deploy_gate_result": {"passed": True},
"deploy_result": {"status": "deployed"},
}
result = build_meeting_result(state)
assert result["verdict"] == "GO"
4 changes: 2 additions & 2 deletions agent/tests/test_runtime_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ async def fake_create_pool(database_url: str, **kwargs):

assert result is pool
assert created["database_url"] == "postgres://example"
assert created["kwargs"]["min_size"] == 1
assert created["kwargs"]["max_size"] == 1
assert created["kwargs"]["min_size"] == 2
assert created["kwargs"]["max_size"] == 10
assert created["kwargs"]["command_timeout"] == 60


Expand Down
2 changes: 1 addition & 1 deletion agent/tests/test_server_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async def test_health_has_provider_field(app_client):
resp = await app_client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert "provider" in data
assert data == {"status": "ok"} # minimal disclosure


@pytest.mark.asyncio
Expand Down
12 changes: 8 additions & 4 deletions agent/tests/test_tools_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1468,7 +1468,8 @@ def test_repo_url_parsed(self, monkeypatch):
assert spec["services"][0]["github"]["repo"] == "myorg/myrepo"

def test_with_database_url(self, monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@host:5432/db")
monkeypatch.setenv("GENERATED_APP_DATABASE_URL", "postgresql://user:pass@host:5432/db")
monkeypatch.delenv("GENERATED_APP_INFERENCE_KEY", raising=False)
monkeypatch.delenv("GRADIENT_MODEL_ACCESS_KEY", raising=False)
monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False)
from agent.tools.digitalocean import build_app_spec
Expand All @@ -1480,7 +1481,8 @@ def test_with_database_url(self, monkeypatch):

def test_with_inference_key(self, monkeypatch):
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.setenv("GRADIENT_MODEL_ACCESS_KEY", "test-inference-key")
monkeypatch.delenv("GENERATED_APP_DATABASE_URL", raising=False)
monkeypatch.setenv("GENERATED_APP_INFERENCE_KEY", "test-inference-key")
monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False)
from agent.tools.digitalocean import build_app_spec

Expand All @@ -1499,7 +1501,8 @@ def test_name_truncated_to_32(self, monkeypatch):
assert len(spec["name"]) <= 32

def test_asyncpg_url_converted_to_psycopg(self, monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgresql+asyncpg://user:pass@host/db")
monkeypatch.setenv("GENERATED_APP_DATABASE_URL", "postgresql+asyncpg://user:pass@host/db")
monkeypatch.delenv("GENERATED_APP_INFERENCE_KEY", raising=False)
monkeypatch.delenv("GRADIENT_MODEL_ACCESS_KEY", raising=False)
monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False)
from agent.tools.digitalocean import build_app_spec
Expand All @@ -1510,7 +1513,8 @@ def test_asyncpg_url_converted_to_psycopg(self, monkeypatch):
assert "asyncpg" not in db_env["value"]

def test_postgres_url_converted(self, monkeypatch):
monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@host/db")
monkeypatch.setenv("GENERATED_APP_DATABASE_URL", "postgres://user:pass@host/db")
monkeypatch.delenv("GENERATED_APP_INFERENCE_KEY", raising=False)
monkeypatch.delenv("GRADIENT_MODEL_ACCESS_KEY", raising=False)
monkeypatch.delenv("DIGITALOCEAN_INFERENCE_KEY", raising=False)
from agent.tools.digitalocean import build_app_spec
Expand Down
Loading