"""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