import os from pathlib import Path import pytest from pydantic import ValidationError from agentci.config.settings import Settings @pytest.fixture(autouse=True) def isolate_agentci_environment(monkeypatch: pytest.MonkeyPatch) -> None: for name in tuple(os.environ): if name.startswith("AGENTCI_"): monkeypatch.delenv(name) def settings(**overrides: object) -> Settings: return Settings(_env_file=None, **overrides) # type: ignore[arg-type,call-arg] def test_reads_values_from_an_isolated_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet") monkeypatch.setenv("AGENTCI_MAX_CONCURRENT_JOBS", "7") value = settings() assert value.install_scripts == ["python", "dotnet"] assert value.max_concurrent_jobs == 7 @pytest.mark.parametrize("field", ["gitea_url", "opencode_url"]) def test_normalizes_service_urls(field: str) -> None: value = settings(**{field: "https://service.example/base///"}) assert getattr(value, field) == "https://service.example/base" @pytest.mark.parametrize( ("field", "boundaries"), [ ("plan_review_rounds", (1, 20)), ("implement_review_rounds", (1, 20)), ("turn_timeout_seconds", (60,)), ("install_script_timeout_seconds", (1,)), ("worker_poll_seconds", (0.1,)), ("max_concurrent_jobs", (1, 32)), ], ) def test_accepts_documented_numeric_boundaries( field: str, boundaries: tuple[int | float, ...] ) -> None: for boundary in boundaries: assert getattr(settings(**{field: boundary}), field) == boundary @pytest.mark.parametrize( ("field", "value"), [ ("plan_review_rounds", 0), ("plan_review_rounds", 21), ("implement_review_rounds", 0), ("implement_review_rounds", 21), ("turn_timeout_seconds", 59), ("install_script_timeout_seconds", 0), ("worker_poll_seconds", 0.09), ("max_concurrent_jobs", 0), ("max_concurrent_jobs", 33), ], ) def test_rejects_values_outside_numeric_boundaries(field: str, value: int | float) -> None: with pytest.raises(ValidationError): settings(**{field: value}) def test_reads_and_strips_secret_files(tmp_path: Path) -> None: token_file = tmp_path / "token" webhook_file = tmp_path / "webhook" password_file = tmp_path / "password" token_file.write_text(" gitea-token\n") webhook_file.write_text(" webhook-secret \n") password_file.write_text("opencode-password\n") value = settings( gitea_token_file=token_file, webhook_secret_file=webhook_file, opencode_server_password_file=password_file, ) assert value.gitea_token == "gitea-token" assert value.webhook_secret == b"webhook-secret" assert value.opencode_server_password == "opencode-password" def test_missing_secret_file_has_actionable_error(tmp_path: Path) -> None: missing = tmp_path / "missing-token" value = settings(gitea_token_file=missing) with pytest.raises(RuntimeError, match="Cannot read Gitea token") as raised: _ = value.gitea_token assert str(missing) in str(raised.value) def test_empty_secret_file_is_rejected(tmp_path: Path) -> None: empty = tmp_path / "webhook" empty.write_text(" \n") value = settings(webhook_secret_file=empty) with pytest.raises(RuntimeError, match="webhook secret file") as raised: _ = value.webhook_secret assert str(empty) in str(raised.value) def test_derived_state_paths_follow_data_directory(tmp_path: Path) -> None: data_dir = tmp_path / "state" value = settings(data_dir=data_dir) assert value.database_path == data_dir / "agentci.sqlite3" assert value.workspaces_dir == data_dir / "workspaces" def test_parses_comma_delimited_install_scripts() -> None: value = settings(install_scripts=" python, dotnet, company-tools, ") assert value.install_scripts == ["python", "dotnet", "company-tools"] @pytest.mark.parametrize("value", ["../script", "tools/setup", "python,python"]) def test_rejects_unsafe_or_duplicate_install_scripts(value: str) -> None: with pytest.raises(ValidationError): settings(install_scripts=value) @pytest.mark.parametrize("value", ["", None, []]) def test_empty_install_scripts_disable_setup(value: object) -> None: assert settings(install_scripts=value).install_scripts == [] def test_agent_defaults_select_expected_capacity_and_research_models() -> None: value = settings() assert value.max_concurrent_jobs == 2 assert (value.explore_model, value.explore_variant) == ( "openai/gpt-5.6-luna", "low", ) assert value.research_variant == "high" @pytest.mark.parametrize( "field", ["plan_model", "implement_model", "explore_model", "research_model"] ) def test_requires_provider_qualified_opencode_models(field: str) -> None: with pytest.raises(ValidationError, match="provider/model"): settings(**{field: "model-only"})