Files
billo-release-agent/tests/test_config.py
Yaojia Wang f5c2733cfb feat: initial commit — Billo Release Agent (LangGraph)
LangGraph-based release automation agent with:
- PR discovery (webhook + polling)
- AI code review via Claude Code CLI (subscription-based)
- Auto-create Jira tickets for PRs without ticket ID
- Jira ticket lifecycle management (code review -> staging -> done)
- CI/CD pipeline trigger, polling, and approval gates
- Slack interactive messages with approval buttons
- Per-repo semantic versioning
- PostgreSQL persistence (threads, staging, releases)
- FastAPI API (webhooks, approvals, status, manual triggers)
- Docker Compose deployment

1069 tests, 95%+ coverage.
2026-03-24 17:38:23 +01:00

451 lines
19 KiB
Python

"""Tests for config module. Written FIRST (TDD RED phase)."""
import os
from unittest.mock import patch
import pytest
from pydantic import SecretStr, ValidationError
from release_agent.config import Settings
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_env() -> dict[str, str]:
"""Return minimal valid environment variables."""
return {
"AZDO_ORGANIZATION": "my-org",
"AZDO_PROJECT": "my-project",
"AZDO_PAT": "super-secret-pat",
"ANTHROPIC_API_KEY": "sk-ant-key",
"POSTGRES_DSN": "postgresql://user:pass@localhost:5432/db",
"JIRA_EMAIL": "user@example.com",
"JIRA_API_TOKEN": "jira-token-abc",
"SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/T000/B000/xxxx",
"WEBHOOK_SECRET": "test-webhook-secret",
}
# ---------------------------------------------------------------------------
# Settings tests
# ---------------------------------------------------------------------------
class TestSettings:
"""Tests for Settings config class."""
def test_loads_from_env_vars(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.azdo_organization == "my-org"
assert settings.azdo_project == "my-project"
def test_pat_is_secret_str(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert isinstance(settings.azdo_pat, SecretStr)
def test_anthropic_key_is_secret_str(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert isinstance(settings.anthropic_api_key, SecretStr)
def test_secret_str_not_leaked_in_repr(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
repr_str = repr(settings)
assert "super-secret-pat" not in repr_str
assert "sk-ant-key" not in repr_str
def test_secret_str_not_leaked_in_str(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
str_repr = str(settings)
assert "super-secret-pat" not in str_repr
def test_missing_required_azdo_org_raises(self) -> None:
env = _base_env()
del env["AZDO_ORGANIZATION"]
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_missing_required_azdo_project_raises(self) -> None:
env = _base_env()
del env["AZDO_PROJECT"]
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_missing_required_pat_raises(self) -> None:
env = _base_env()
del env["AZDO_PAT"]
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_missing_anthropic_key_is_optional(self) -> None:
env = _base_env()
del env["ANTHROPIC_API_KEY"]
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.anthropic_api_key.get_secret_value() == ""
def test_missing_postgres_dsn_raises(self) -> None:
env = _base_env()
del env["POSTGRES_DSN"]
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_azdo_base_url_computed(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
expected = "https://dev.azure.com/my-org"
assert settings.azdo_base_url == expected
def test_azdo_api_url_computed(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
expected = "https://dev.azure.com/my-org/my-project/_apis"
assert settings.azdo_api_url == expected
def test_default_port(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.port == 8000
def test_custom_port_from_env(self) -> None:
env = {**_base_env(), "PORT": "9000"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.port == 9000
def test_port_below_minimum_raises(self) -> None:
env = {**_base_env(), "PORT": "0"}
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_port_above_maximum_raises(self) -> None:
env = {**_base_env(), "PORT": "65536"}
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_port_minimum_valid(self) -> None:
env = {**_base_env(), "PORT": "1"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.port == 1
def test_port_maximum_valid(self) -> None:
env = {**_base_env(), "PORT": "65535"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.port == 65535
def test_get_pat_value(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.azdo_pat.get_secret_value() == "super-secret-pat"
def test_get_anthropic_key_value(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.anthropic_api_key.get_secret_value() == "sk-ant-key"
def test_postgres_dsn_is_secret_str(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert isinstance(settings.postgres_dsn, SecretStr)
assert "localhost" in settings.postgres_dsn.get_secret_value()
def test_postgres_dsn_not_leaked_in_repr(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert "user:pass" not in repr(settings)
class TestSettingsPhase2:
"""Tests for Phase 2 settings fields."""
def test_jira_base_url_default(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.jira_base_url == "https://billolife.atlassian.net"
def test_jira_base_url_custom(self) -> None:
env = {**_base_env(), "JIRA_BASE_URL": "https://custom.atlassian.net"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.jira_base_url == "https://custom.atlassian.net"
def test_jira_email_required(self) -> None:
env = _base_env()
del env["JIRA_EMAIL"]
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_jira_email_stored(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.jira_email == "user@example.com"
def test_jira_api_token_required(self) -> None:
env = _base_env()
del env["JIRA_API_TOKEN"]
with patch.dict(os.environ, env, clear=True), pytest.raises(ValidationError):
Settings()
def test_jira_api_token_is_secret_str(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert isinstance(settings.jira_api_token, SecretStr)
def test_jira_api_token_not_leaked_in_repr(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert "jira-token-abc" not in repr(settings)
def test_slack_webhook_url_optional_defaults_empty(self) -> None:
env = _base_env()
del env["SLACK_WEBHOOK_URL"]
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.slack_webhook_url.get_secret_value() == ""
def test_slack_webhook_url_is_secret_str(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert isinstance(settings.slack_webhook_url, SecretStr)
def test_slack_webhook_url_not_leaked_in_repr(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert "xxxx" not in repr(settings)
def test_claude_review_model_default(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.claude_review_model == "claude-sonnet-4-20250514"
def test_claude_review_model_custom(self) -> None:
env = {**_base_env(), "CLAUDE_REVIEW_MODEL": "claude-opus-4-20250514"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.claude_review_model == "claude-opus-4-20250514"
def test_azdo_vsrm_api_url_computed(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
expected = "https://vsrm.dev.azure.com/my-org/my-project/_apis"
assert settings.azdo_vsrm_api_url == expected
class TestSettingsPhase4:
"""Tests for Phase 4 settings fields (webhook secret)."""
def test_webhook_secret_optional_defaults_empty(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
# When not provided, defaults to empty string or None
assert settings.webhook_secret is not None or settings.webhook_secret == ""
def test_webhook_secret_custom_value(self) -> None:
env = {**_base_env(), "WEBHOOK_SECRET": "my-super-secret"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.webhook_secret.get_secret_value() == "my-super-secret"
def test_webhook_secret_is_secret_str(self) -> None:
env = {**_base_env(), "WEBHOOK_SECRET": "secret-value"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert isinstance(settings.webhook_secret, SecretStr)
def test_webhook_secret_not_leaked_in_repr(self) -> None:
env = {**_base_env(), "WEBHOOK_SECRET": "super-private-secret"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert "super-private-secret" not in repr(settings)
class TestSettingsPhase5:
"""Tests for Phase 5 settings fields (Slack Web API + CI polling)."""
def test_slack_webhook_url_optional_when_bot_token_provided(self) -> None:
env = {k: v for k, v in _base_env().items() if k != "SLACK_WEBHOOK_URL"}
env["SLACK_BOT_TOKEN"] = "xoxb-test-token"
env["SLACK_CHANNEL_ID"] = "C12345678"
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.slack_bot_token is not None
assert settings.slack_bot_token.get_secret_value() == "xoxb-test-token"
def test_slack_bot_token_optional_defaults_empty(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.slack_bot_token.get_secret_value() == ""
def test_slack_bot_token_is_secret_str(self) -> None:
env = {**_base_env(), "SLACK_BOT_TOKEN": "xoxb-abc-123"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert isinstance(settings.slack_bot_token, SecretStr)
def test_slack_bot_token_not_leaked_in_repr(self) -> None:
env = {**_base_env(), "SLACK_BOT_TOKEN": "xoxb-super-secret"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert "xoxb-super-secret" not in repr(settings)
def test_slack_signing_secret_optional_defaults_empty(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.slack_signing_secret.get_secret_value() == ""
def test_slack_signing_secret_custom_value(self) -> None:
env = {**_base_env(), "SLACK_SIGNING_SECRET": "signing-secret-xyz"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.slack_signing_secret.get_secret_value() == "signing-secret-xyz"
def test_slack_signing_secret_is_secret_str(self) -> None:
env = {**_base_env(), "SLACK_SIGNING_SECRET": "some-secret"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert isinstance(settings.slack_signing_secret, SecretStr)
def test_slack_signing_secret_not_leaked_in_repr(self) -> None:
env = {**_base_env(), "SLACK_SIGNING_SECRET": "private-signing-secret"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert "private-signing-secret" not in repr(settings)
def test_slack_channel_id_optional_defaults_empty(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.slack_channel_id == ""
def test_slack_channel_id_custom_value(self) -> None:
env = {**_base_env(), "SLACK_CHANNEL_ID": "C0987654321"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.slack_channel_id == "C0987654321"
def test_ci_poll_interval_seconds_default(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.ci_poll_interval_seconds == 30
def test_ci_poll_interval_seconds_custom(self) -> None:
env = {**_base_env(), "CI_POLL_INTERVAL_SECONDS": "60"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.ci_poll_interval_seconds == 60
def test_ci_poll_max_wait_seconds_default(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.ci_poll_max_wait_seconds == 1800
def test_ci_poll_max_wait_seconds_custom(self) -> None:
env = {**_base_env(), "CI_POLL_MAX_WAIT_SECONDS": "3600"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.ci_poll_max_wait_seconds == 3600
def test_slack_webhook_url_still_optional(self) -> None:
env = {k: v for k, v in _base_env().items() if k != "SLACK_WEBHOOK_URL"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.slack_webhook_url.get_secret_value() == ""
class TestSettingsPrPolling:
"""Tests for PR polling config fields (Step 1)."""
def test_watched_repos_defaults_empty(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.watched_repos == ""
def test_watched_repos_custom_value(self) -> None:
env = {**_base_env(), "WATCHED_REPOS": "repo-a,repo-b"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.watched_repos == "repo-a,repo-b"
def test_watched_repos_list_empty_when_blank(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.watched_repos_list == []
def test_watched_repos_list_splits_comma_separated(self) -> None:
env = {**_base_env(), "WATCHED_REPOS": "repo-a,repo-b,repo-c"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.watched_repos_list == ["repo-a", "repo-b", "repo-c"]
def test_watched_repos_list_strips_whitespace(self) -> None:
env = {**_base_env(), "WATCHED_REPOS": " repo-a , repo-b "}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.watched_repos_list == ["repo-a", "repo-b"]
def test_watched_repos_list_ignores_empty_entries(self) -> None:
env = {**_base_env(), "WATCHED_REPOS": "repo-a,,repo-b"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.watched_repos_list == ["repo-a", "repo-b"]
def test_pr_poll_interval_seconds_default(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.pr_poll_interval_seconds == 300
def test_pr_poll_interval_seconds_custom(self) -> None:
env = {**_base_env(), "PR_POLL_INTERVAL_SECONDS": "60"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.pr_poll_interval_seconds == 60
def test_pr_poll_target_branch_default(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.pr_poll_target_branch == "refs/heads/develop"
def test_pr_poll_target_branch_custom(self) -> None:
env = {**_base_env(), "PR_POLL_TARGET_BRANCH": "refs/heads/main"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.pr_poll_target_branch == "refs/heads/main"
def test_pr_poll_enabled_defaults_false(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.pr_poll_enabled is False
def test_pr_poll_enabled_true_from_env(self) -> None:
env = {**_base_env(), "PR_POLL_ENABLED": "true"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.pr_poll_enabled is True
def test_default_jira_project_default(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.default_jira_project == "ALLPOST"
def test_default_jira_project_custom(self) -> None:
env = {**_base_env(), "DEFAULT_JIRA_PROJECT": "MYPROJ"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.default_jira_project == "MYPROJ"
def test_auto_create_ticket_enabled_defaults_true(self) -> None:
with patch.dict(os.environ, _base_env(), clear=True):
settings = Settings()
assert settings.auto_create_ticket_enabled is True
def test_auto_create_ticket_enabled_false_from_env(self) -> None:
env = {**_base_env(), "AUTO_CREATE_TICKET_ENABLED": "false"}
with patch.dict(os.environ, env, clear=True):
settings = Settings()
assert settings.auto_create_ticket_enabled is False