This commit is contained in:
47
tests/test_config.py
Normal file
47
tests/test_config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lai_automation.config import Settings
|
||||
|
||||
|
||||
def settings(**overrides) -> Settings:
|
||||
values = {
|
||||
"database_host": "db",
|
||||
"database_port": 3306,
|
||||
"database_name": "lai",
|
||||
"database_user": "worker",
|
||||
"database_password": "secret",
|
||||
"source_repository": "https://example.test/lai.git",
|
||||
"source_branch": "main",
|
||||
"workspace_root": Path("/tmp/workspaces"),
|
||||
"mode": "dry-run",
|
||||
"allow_direct_main": False,
|
||||
"gitea_url": "https://example.test",
|
||||
"gitea_owner": "owner",
|
||||
"gitea_repository": "lai",
|
||||
"gitea_token": "",
|
||||
"gitea_username": "bot",
|
||||
"agent_command": "aider",
|
||||
"agent_model": "mistral/test",
|
||||
"test_commands": ("php artisan test",),
|
||||
"poll_seconds": 60,
|
||||
"max_changed_files": 12,
|
||||
"max_changed_lines": 800,
|
||||
"stale_claim_minutes": 90,
|
||||
"dry_run": True,
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def test_direct_main_requires_explicit_kill_switch() -> None:
|
||||
config = settings(mode="direct-main", dry_run=False, gitea_token="token")
|
||||
with pytest.raises(ValueError, match="ALLOW_DIRECT_MAIN"):
|
||||
config.validate()
|
||||
|
||||
|
||||
def test_pr_requires_gitea_token() -> None:
|
||||
config = settings(mode="pr", dry_run=False)
|
||||
with pytest.raises(ValueError, match="GITEA_TOKEN"):
|
||||
config.validate()
|
||||
28
tests/test_git.py
Normal file
28
tests/test_git.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import subprocess
|
||||
|
||||
from test_config import settings
|
||||
|
||||
from lai_automation.git import GitWorkspace
|
||||
|
||||
|
||||
def test_test_command_does_not_interpret_shell_metacharacters(tmp_path) -> None:
|
||||
workspace = GitWorkspace(settings(workspace_root=tmp_path), 1)
|
||||
workspace.path.mkdir()
|
||||
|
||||
result = workspace.run("printf safe ; touch injected", check=False)
|
||||
|
||||
assert result.stdout == "safe"
|
||||
assert result.returncode != 0
|
||||
assert not (workspace.path / "injected").exists()
|
||||
|
||||
|
||||
def test_summary_includes_untracked_files(tmp_path) -> None:
|
||||
workspace = GitWorkspace(settings(workspace_root=tmp_path), 2)
|
||||
workspace.path.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=workspace.path, check=True, capture_output=True)
|
||||
(workspace.path / "new.php").write_text("<?php\n")
|
||||
|
||||
summary = workspace.summary()
|
||||
|
||||
assert summary.files == ("new.php",)
|
||||
assert summary.added_lines == 1
|
||||
27
tests/test_gitea.py
Normal file
27
tests/test_gitea.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from test_config import settings
|
||||
|
||||
from lai_automation.gitea import GiteaClient
|
||||
|
||||
|
||||
def test_create_pull_request_requires_url() -> None:
|
||||
client = GiteaClient(settings(gitea_token="token"))
|
||||
response = Mock()
|
||||
response.json.return_value = {"number": 12}
|
||||
client.session.post = Mock(return_value=response)
|
||||
|
||||
with pytest.raises(ValueError, match="pull request URL"):
|
||||
client.create_pull_request("ai/failure-1", "Fix", "Body")
|
||||
|
||||
|
||||
def test_create_pull_request_returns_html_url() -> None:
|
||||
client = GiteaClient(settings(gitea_token="token"))
|
||||
response = Mock()
|
||||
response.json.return_value = {"html_url": "https://example.test/owner/lai/pulls/12"}
|
||||
client.session.post = Mock(return_value=response)
|
||||
|
||||
result = client.create_pull_request("ai/failure-1", "Fix", "Body")
|
||||
|
||||
assert result.endswith("/pulls/12")
|
||||
41
tests/test_policy.py
Normal file
41
tests/test_policy.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from dataclasses import replace
|
||||
|
||||
from test_config import settings
|
||||
|
||||
from lai_automation.git import ChangeSummary
|
||||
from lai_automation.policy import evaluate_changes
|
||||
|
||||
|
||||
def test_policy_accepts_small_application_change() -> None:
|
||||
result = evaluate_changes(
|
||||
ChangeSummary(("app/Services/Example.php", "tests/Feature/ExampleTest.php"), 40, 5),
|
||||
settings(),
|
||||
)
|
||||
assert result.allowed
|
||||
|
||||
|
||||
def test_policy_rejects_secrets_and_vendor_paths() -> None:
|
||||
result = evaluate_changes(ChangeSummary((".env", "vendor/foo.php"), 2, 0), settings())
|
||||
assert not result.allowed
|
||||
assert len(result.errors) == 2
|
||||
|
||||
|
||||
def test_direct_main_blocks_validator_and_migrations() -> None:
|
||||
config = replace(
|
||||
settings(),
|
||||
mode="direct-main",
|
||||
dry_run=False,
|
||||
allow_direct_main=True,
|
||||
gitea_token="token",
|
||||
)
|
||||
result = evaluate_changes(
|
||||
ChangeSummary(("app/Services/SQLValidatorService.php", "database/migrations/new.php"), 10, 2),
|
||||
config,
|
||||
)
|
||||
assert not result.allowed
|
||||
|
||||
|
||||
def test_policy_rejects_symlinks(tmp_path) -> None:
|
||||
(tmp_path / "link.php").symlink_to("/etc/passwd")
|
||||
result = evaluate_changes(ChangeSummary(("link.php",), 1, 0), settings(), tmp_path)
|
||||
assert not result.allowed
|
||||
47
tests/test_prompt.py
Normal file
47
tests/test_prompt.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from lai_automation.models import FailureReport
|
||||
from lai_automation.prompt import build_task, redact
|
||||
|
||||
|
||||
def test_redact_removes_common_secrets_and_personal_markers() -> None:
|
||||
value = "password=hunter2 token: abc123 admin@example.com 10.2.3.4 Bearer secret.jwt"
|
||||
result = redact(value)
|
||||
assert "hunter2" not in result
|
||||
assert "abc123" not in result
|
||||
assert "admin@example.com" not in result
|
||||
assert "10.2.3.4" not in result
|
||||
assert "secret.jwt" not in result
|
||||
|
||||
|
||||
def test_task_contains_constraints_and_redacted_report() -> None:
|
||||
report = FailureReport(
|
||||
id=7,
|
||||
chat_message_id=91,
|
||||
source="user_feedback",
|
||||
category="incorrect_answer",
|
||||
user_feedback="password=secret-value",
|
||||
prompt="List locations for admin@example.com",
|
||||
response=None,
|
||||
sql_query="SELECT * FROM company",
|
||||
error_message=None,
|
||||
context={"model": "mistral", "database_type": "customer"},
|
||||
)
|
||||
task = build_task(report)
|
||||
assert "failure report #7" in task
|
||||
assert "secret-value" not in task
|
||||
assert "admin@example.com" not in task
|
||||
assert "Do not commit or push" in task
|
||||
|
||||
|
||||
def test_redact_removes_extended_credentials() -> None:
|
||||
private_key = "-----BEGIN PRIVATE KEY-----\nprivate-material\n-----END PRIVATE KEY-----"
|
||||
value = (
|
||||
"AKIAIOSFODNN7EXAMPLE "
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature "
|
||||
"mysql://admin:database-password@db.internal/lai "
|
||||
f"{private_key}"
|
||||
)
|
||||
result = redact(value)
|
||||
assert "AKIAIOSFODNN7EXAMPLE" not in result
|
||||
assert "eyJhbGci" not in result
|
||||
assert "database-password" not in result
|
||||
assert "private-material" not in result
|
||||
37
tests/test_worker.py
Normal file
37
tests/test_worker.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from test_config import settings
|
||||
|
||||
from lai_automation.models import FailureReport
|
||||
from lai_automation.worker import AutomationWorker
|
||||
|
||||
|
||||
def test_dry_run_generates_task_without_agent_or_git() -> None:
|
||||
report = FailureReport(
|
||||
id=11,
|
||||
chat_message_id=1,
|
||||
source="automatic",
|
||||
category="sql_execution",
|
||||
user_feedback=None,
|
||||
prompt="List locations",
|
||||
response=None,
|
||||
sql_query=None,
|
||||
error_message="Unknown column",
|
||||
context={},
|
||||
)
|
||||
worker = AutomationWorker(settings())
|
||||
worker.reports = Mock()
|
||||
worker.reports.claim_next.return_value = report
|
||||
|
||||
assert worker.run_once()
|
||||
worker.reports.release.assert_called_once()
|
||||
worker.agent = Mock()
|
||||
worker.agent.run.assert_not_called()
|
||||
|
||||
|
||||
def test_stop_signal_interrupts_worker_wait() -> None:
|
||||
worker = AutomationWorker(settings())
|
||||
|
||||
worker._stop(15, None)
|
||||
|
||||
assert worker.stop_event.is_set()
|
||||
Reference in New Issue
Block a user