Files
stack/tests/api/test_diag_ci.py
kert 34fb8c8196
All checks were successful
CI / lint (push) Successful in 34s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Deploy / report (push) Successful in 30s
CI / test (push) Successful in 29m15s
fix: add failure steps to deploy jobs + skip renovate in workflow test
2026-04-18 20:38:28 -04:00

278 lines
8.5 KiB
Python

"""Tests for the backend-agnostic CI failure reporter."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
class TestFileCiFailure:
"""file_ci_failure builds correct issue title and body."""
@patch("api.diag.ci._get_token", return_value="fake-token")
@patch("api.clients.gitea.GiteaClient")
def test_files_issue_with_correct_title(self, mock_cls, mock_token):
mock_client = MagicMock()
mock_client.create_issue.return_value = {"number": 999}
mock_client.resolve_labels.return_value = [1]
mock_cls.return_value = mock_client
from api.diag.ci import file_ci_failure
result = file_ci_failure(
workflow="Deploy",
job="build-scan-report",
run="42",
sha="abc12345def67890",
ref="refs/heads/main",
)
assert result is not None
assert result["number"] == 999
call_args = mock_client.create_issue.call_args
issue_body = call_args[0][2]
assert "Deploy" in issue_body["title"]
assert "build-scan-report" in issue_body["title"]
assert "abc12345" in issue_body["title"]
assert "#42" in issue_body["title"]
@patch("api.diag.ci._get_token", return_value="fake-token")
@patch("api.clients.gitea.GiteaClient")
def test_body_includes_metadata(self, mock_cls, mock_token):
mock_client = MagicMock()
mock_client.create_issue.return_value = {"number": 1}
mock_client.resolve_labels.return_value = []
mock_cls.return_value = mock_client
from api.diag.ci import file_ci_failure
file_ci_failure(
workflow="CI",
job="lint-test",
run="99",
sha="deadbeef",
ref="refs/heads/feature",
)
issue_body = mock_client.create_issue.call_args[0][2]
body = issue_body["body"]
assert "CI" in body
assert "lint-test" in body
assert "#99" in body
assert "deadbeef" in body
assert "refs/heads/feature" in body
@patch("api.diag.ci._get_token", return_value="fake-token")
@patch("api.clients.gitea.GiteaClient")
def test_resolves_pipeline_label(self, mock_cls, mock_token):
mock_client = MagicMock()
mock_client.create_issue.return_value = {"number": 2}
mock_client.resolve_labels.return_value = [5, 10]
mock_cls.return_value = mock_client
from api.diag.ci import file_ci_failure
file_ci_failure(
workflow="Deploy",
job="build",
run="1",
sha="aaa",
ref="",
)
label_call = mock_client.resolve_labels.call_args
label_names = label_call[0][2]
assert "ci" in label_names
assert "pipeline:deploy" in label_names
@patch("api.diag.ci._get_token", return_value="")
def test_no_token_returns_none(self, mock_token):
from api.diag.ci import file_ci_failure
result = file_ci_failure(
workflow="CI",
job="test",
run="1",
sha="aaa",
ref="",
)
assert result is None
class TestCLI:
"""CLI entry point parses args correctly."""
@patch("api.diag.ci.file_ci_failure")
def test_main_calls_file_ci_failure(self, mock_file):
mock_file.return_value = {"number": 1}
import sys
from api.diag.ci import main
old_argv = sys.argv
sys.argv = [
"ci",
"--workflow",
"CI",
"--job",
"lint-test",
"--run",
"5",
"--sha",
"abc123",
"--ref",
"refs/heads/main",
]
try:
code = main()
finally:
sys.argv = old_argv
assert code == 0
mock_file.assert_called_once()
kwargs = mock_file.call_args[1]
assert kwargs["workflow"] == "CI"
assert kwargs["job"] == "lint-test"
assert kwargs["run"] == "5"
assert kwargs["sha"] == "abc123"
class TestWorkflowGeneration:
"""Generated workflows contain failure steps."""
def test_gitea_workflows_have_failure_steps(self):
from pathlib import Path
wf_dir = Path(__file__).resolve().parents[2] / ".gitea" / "workflows"
skip = {"renovate.yml"} # external bot, no failure filing
for yml in wf_dir.glob("*.yml"):
if yml.name in skip:
continue
content = yml.read_text()
assert "File failure issue" in content, f"{yml.name} missing failure step"
class TestGetToken:
def test_returns_gitea_token_from_env(self, monkeypatch) -> None:
monkeypatch.setenv("GITEA_TOKEN", "env-token")
from api.diag.ci import _get_token
assert _get_token() == "env-token"
def test_falls_back_to_conf_secret(self, monkeypatch) -> None:
monkeypatch.delenv("GITEA_TOKEN", raising=False)
# Force the conf.secret import path to return a value.
import sys
import types
from api.diag.ci import _get_token
fake_conf = types.ModuleType("conf")
fake_conf.secret = lambda key, env: "conf-token"
monkeypatch.setitem(sys.modules, "conf", fake_conf)
assert _get_token() == "conf-token"
def test_returns_empty_when_conf_unavailable(self, monkeypatch) -> None:
"""ImportError on conf import → falls through to '' return."""
monkeypatch.delenv("GITEA_TOKEN", raising=False)
import sys
from api.diag.ci import _get_token
real_import = (
__builtins__["__import__"]
if isinstance(__builtins__, dict)
else __builtins__.__import__
)
def fake_import(name, *a, **kw):
if name == "conf":
raise ImportError("conf unavailable")
return real_import(name, *a, **kw)
monkeypatch.setattr("builtins.__import__", fake_import)
monkeypatch.delitem(sys.modules, "conf", raising=False)
assert _get_token() == ""
def test_returns_empty_when_no_token(self, monkeypatch) -> None:
monkeypatch.delenv("GITEA_TOKEN", raising=False)
from unittest.mock import patch
from api.diag.ci import _get_token
with patch("api.diag.ci._get_token", return_value=""):
assert _get_token.__module__ == "api.diag.ci"
class TestFileCiFailureGithubAction:
@patch("api.diag.ci._get_token", return_value="fake-token")
@patch("api.clients.gitea.GiteaClient")
def test_github_action_env_added_to_body(self, mock_cls, mock_token, monkeypatch):
"""GITHUB_ACTION env var triggers step line in body."""
monkeypatch.setenv("GITHUB_ACTION", "my-step-name")
mock_client = MagicMock()
mock_client.create_issue.return_value = {"number": 10}
mock_client.resolve_labels.return_value = []
mock_cls.return_value = mock_client
from api.diag.ci import file_ci_failure
result = file_ci_failure(
workflow="CI",
job="test",
run="1",
sha="abc",
ref="",
)
assert result is not None
issue_body = mock_client.create_issue.call_args[0][2]
assert "my-step-name" in issue_body["body"]
@patch("api.diag.ci._get_token", return_value="fake-token")
@patch("api.clients.gitea.GiteaClient")
def test_exception_in_create_issue_returns_none(self, mock_cls, mock_token):
"""Exception during Gitea API call returns None."""
mock_client = MagicMock()
mock_client.create_issue.side_effect = Exception("API error")
mock_client.resolve_labels.return_value = []
mock_cls.return_value = mock_client
from api.diag.ci import file_ci_failure
result = file_ci_failure(
workflow="CI",
job="test",
run="1",
sha="abc",
ref="",
)
assert result is None
class TestCLIFailure:
@patch("api.diag.ci.file_ci_failure")
def test_main_returns_1_when_no_result(self, mock_file):
mock_file.return_value = None
import sys
from api.diag.ci import main
old_argv = sys.argv
sys.argv = [
"ci",
"--workflow",
"CI",
"--job",
"test",
"--run",
"1",
"--sha",
"abc",
]
try:
code = main()
finally:
sys.argv = old_argv
assert code == 1