commit all of this
All checks were successful
CI / test (push) Successful in 27s

This commit is contained in:
zphinx
2026-05-14 20:00:38 +02:00
parent 2d8a5a66ca
commit 3be14f8f6f
15 changed files with 2138 additions and 131 deletions

View File

@@ -1,11 +1,22 @@
import json
import os
import re
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from typer.testing import CliRunner
from tai.cli import app
from tai.cli import (
_download_markdown_url,
_inject_url_credentials,
_load_env_file,
_materialize_runbook_add_path,
_materialize_runbooks_sync_path,
_resolve_secret,
app,
)
from tai.collectors import CollectedItem, CollectionReport
from tai.rag_retriever import Chunk, EmbeddedChunk
from tai.ssh_client import SSHCommandResult
@@ -344,7 +355,7 @@ def test_interactive_rag_debug_prints_retrieval_scores(monkeypatch) -> None: #
def test_history_command_lists_sessions(monkeypatch) -> None: # type: ignore[no-untyped-def]
class FakeStore:
def __init__(self, _path: str) -> None:
def __init__(self, _path: str, **_kwargs) -> None:
pass
def list_recent(self, *, host: str | None = None, limit: int = 20):
@@ -360,12 +371,12 @@ def test_history_command_lists_sessions(monkeypatch) -> None: # type: ignore[no
]
return []
monkeypatch.setattr("tai.cli.SessionStore", FakeStore)
monkeypatch.setattr("tai.cli.RunHistoryStore", FakeStore)
runner = CliRunner()
result = runner.invoke(
app,
["history", "--session-memory", "~/.tai/sessions", "--host", "web01"],
["history", "--history-db", "~/.tai/history.db", "--host", "web01"],
)
assert result.exit_code == 0
@@ -375,7 +386,7 @@ def test_history_command_lists_sessions(monkeypatch) -> None: # type: ignore[no
def test_history_command_exports_markdown(monkeypatch, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
class FakeStore:
def __init__(self, _path: str) -> None:
def __init__(self, _path: str, **_kwargs) -> None:
pass
def list_recent(self, *, host: str | None = None, limit: int = 20):
@@ -389,13 +400,13 @@ def test_history_command_exports_markdown(monkeypatch, tmp_path: Path) -> None:
)
]
monkeypatch.setattr("tai.cli.SessionStore", FakeStore)
monkeypatch.setattr("tai.cli.RunHistoryStore", FakeStore)
export_path = tmp_path / "history.md"
runner = CliRunner()
result = runner.invoke(
app,
["history", "--session-memory", "~/.tai/sessions", "--export", str(export_path)],
["history", "--history-db", "~/.tai/history.db", "--export", str(export_path)],
)
assert result.exit_code == 0
@@ -440,11 +451,12 @@ def test_interactive_history_without_store_shows_hint(monkeypatch) -> None: # t
"5566",
"--no-probe",
"--interactive",
"--no-history",
],
)
assert result.exit_code == 0
assert "Session memory is disabled" in result.stdout
assert "History DB is disabled" in result.stdout
def test_run_analyze_writes_output_file(monkeypatch, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
@@ -467,9 +479,10 @@ def test_run_analyze_writes_output_file(monkeypatch, tmp_path: Path) -> None: #
)
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
response = SimpleNamespace(content="Root Cause\n\nEvidence\n\nRecommended Actions")
monkeypatch.setattr(
"tai.cli.AIClient.complete",
lambda *_args, **_kwargs: SimpleNamespace(content="Root Cause\n\nEvidence\n\nRecommended Actions"),
lambda *_args, **_kwargs: response,
)
output_path = tmp_path / "analysis.md"
@@ -543,7 +556,304 @@ def test_run_analyze_writes_json_output_and_strips_ansi(monkeypatch, tmp_path: P
assert result.exit_code == 0
payload = json.loads(output_path.read_text(encoding="utf-8"))
assert payload["schema"] == "tai.analysis.v1"
assert "generated_at" in payload
assert payload["issue"] == "apache failed"
assert payload["host"] == "ssh.archflux.net"
assert payload["collection"] == {"total": 1, "failed": 0, "succeeded": 1}
assert payload["token_usage"] == {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
}
assert "Root Cause" in payload["analysis"]
assert "\u001b" not in payload["analysis"]
def test_run_analyze_writes_history_db_record(monkeypatch, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
_mock_session(monkeypatch)
async def fake_collect_from_plan(_session, _plan) -> CollectionReport: # type: ignore[no-untyped-def]
return CollectionReport(
host="ssh.archflux.net",
items=[
CollectedItem(
name="kernel",
result=SSHCommandResult(
command="uname -a",
exit_code=0,
stdout="Linux test",
stderr="",
),
),
],
)
monkeypatch.setattr("tai.cli.collect_from_plan", fake_collect_from_plan)
response = SimpleNamespace(content="Root Cause\n\nEvidence\n\nRecommended Actions")
monkeypatch.setattr(
"tai.cli.AIClient.complete",
lambda *_args, **_kwargs: response,
)
history_db = tmp_path / "history.db"
runner = CliRunner()
result = runner.invoke(
app,
[
"run", "apache failed",
"--host",
"ssh.archflux.net",
"--port",
"5566",
"--no-probe",
"--analyze",
"--history-db",
str(history_db),
],
)
assert result.exit_code == 0
import sqlite3
with sqlite3.connect(str(history_db)) as conn:
row = conn.execute(
"SELECT host, issue, payload_json FROM run_history ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None
assert row[0] == "ssh.archflux.net"
assert row[1] == "apache failed"
payload = json.loads(row[2])
assert payload["schema"] == "tai.analysis.v1"
assert payload["host"] == "ssh.archflux.net"
def test_materialize_runbooks_sync_path_http_webroot(monkeypatch, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
html = '<html><body><a href="nginx.md">nginx</a><a href="ssh.md">ssh</a></body></html>'
def fake_download(url: str) -> str:
if url == "https://kb.example/runbooks/":
return html
if url.endswith("nginx.md"):
return "---\nservice: nginx\n---\nbody"
if url.endswith("ssh.md"):
return "---\nservice: ssh\n---\nbody"
raise AssertionError(url)
monkeypatch.setattr("tai.cli._download_text_url", fake_download)
source_dir, label, temp_dir = _materialize_runbooks_sync_path(
"https://kb.example/runbooks/",
identity_file=None,
jump_host=None,
ignore_ssh_config=False,
)
assert label == "https://kb.example/runbooks/"
assert temp_dir is not None
assert (source_dir / "nginx.md").is_file()
assert (source_dir / "ssh.md").is_file()
def test_materialize_runbook_add_path_http_url(monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setattr(
"tai.cli._download_markdown_url",
lambda _url: "---\nservice: nginx\n---\nbody",
)
source_file, label, temp_dir = _materialize_runbook_add_path(
"https://kb.example/runbooks/nginx.md",
identity_file=None,
jump_host=None,
ignore_ssh_config=False,
)
assert label == "https://kb.example/runbooks/nginx.md"
assert temp_dir is not None
assert source_file.name == "nginx.md"
assert source_file.read_text(encoding="utf-8").startswith("---")
def test_download_markdown_url_rejects_html(monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setattr(
"tai.cli._download_text_url",
lambda _url: "<!DOCTYPE html><html><body>not markdown</body></html>",
)
with pytest.raises(ValueError, match="does not appear to be a Markdown payload"):
_download_markdown_url("https://kb.example/runbooks/nginx.md")
def test_materialize_runbooks_sync_path_http_skips_html_wrappers(monkeypatch) -> None: # type: ignore[no-untyped-def]
html = '<html><body><a href="nginx.md">nginx</a><a href="ssh.md">ssh</a></body></html>'
def fake_download(url: str) -> str:
if url == "https://kb.example/runbooks/":
return html
if url.endswith("nginx.md"):
return "---\nservice: nginx\n---\nbody"
if url.endswith("ssh.md"):
return "<!DOCTYPE html><html><body>wrapper</body></html>"
raise AssertionError(url)
monkeypatch.setattr("tai.cli._download_text_url", fake_download)
source_dir, _label, temp_dir = _materialize_runbooks_sync_path(
"https://kb.example/runbooks/",
identity_file=None,
jump_host=None,
ignore_ssh_config=False,
)
assert temp_dir is not None
assert (source_dir / "nginx.md").is_file()
assert not (source_dir / "ssh.md").exists()
def test_materialize_runbook_add_path_http_requires_md_suffix() -> None:
with pytest.raises(ValueError, match="must point to a .md file"):
_materialize_runbook_add_path(
"https://kb.example/runbooks/",
identity_file=None,
jump_host=None,
ignore_ssh_config=False,
)
def test_runbooks_sync_accepts_ssh_source(monkeypatch, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
runbooks_dir = tmp_path / "remote-runbooks"
runbooks_dir.mkdir(parents=True)
(runbooks_dir / "nginx.md").write_text("---\nservice: nginx\n---\nbody", encoding="utf-8")
monkeypatch.setattr(
"tai.cli._materialize_runbooks_sync_path",
lambda *_args, **_kwargs: (runbooks_dir, "ssh://ops@host/runbooks", None),
)
class FakeStore:
def __init__(self, _path: str, **_kwargs) -> None:
pass
def sync(self, _dir: Path, _ai):
return 1
monkeypatch.setattr("tai.cli.RunbookStore", FakeStore)
monkeypatch.setattr("tai.cli.AIClient", lambda *_a, **_k: object())
runner = CliRunner()
result = runner.invoke(
app,
[
"runbooks",
"sync",
"--path",
"ssh://ops@host/runbooks",
"--store",
"~/.tai/runbooks",
],
)
assert result.exit_code == 0
assert "Synced 1 runbook(s)" in result.stdout
assert "ssh://ops@host/runbooks" in result.stdout
def test_runbooks_add_accepts_https_source(monkeypatch) -> None: # type: ignore[no-untyped-def]
import tempfile
fd, temp_name = tempfile.mkstemp(prefix="tai-runbook-test-", suffix=".md")
os.close(fd)
Path(temp_name).write_text("---\nservice: nginx\n---\nbody", encoding="utf-8")
monkeypatch.setattr(
"tai.cli._materialize_runbook_add_path",
lambda *_args, **_kwargs: (Path(temp_name), "https://kb.example/nginx.md", None),
)
class FakeStore:
def __init__(self, _path: str, **_kwargs) -> None:
pass
def sync_single(self, _path: Path, _ai):
return None
monkeypatch.setattr("tai.cli.RunbookStore", FakeStore)
monkeypatch.setattr("tai.cli.AIClient", lambda *_a, **_k: object())
runner = CliRunner()
result = runner.invoke(
app,
[
"runbooks",
"add",
"https://kb.example/nginx.md",
"--store",
"~/.tai/runbooks",
],
)
assert result.exit_code == 0
assert "Indexed" in result.stdout
assert "https://kb.example/nginx.md" in result.stdout
Path(temp_name).unlink(missing_ok=True)
def test_inject_url_credentials_postgres() -> None:
target = "postgresql://db.example.com:5432/tai"
rendered = _inject_url_credentials(
target,
user="tai_user",
password="secret",
schemes={"postgresql", "postgres"},
)
assert rendered.startswith("postgresql://tai_user:secret@db.example.com:5432/tai")
def test_inject_url_credentials_ignores_non_matching_scheme() -> None:
target = "~/.tai/history.db"
rendered = _inject_url_credentials(
target,
user="tai_user",
password="secret",
schemes={"postgresql", "postgres"},
)
assert rendered == target
def test_load_env_file_and_resolve_secret(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
env_file = tmp_path / ".env"
env_file.write_text(
"TAI_HISTORY_DB_USER=from_file\n"
"TAI_HISTORY_DB_PASSWORD=from_file_pw\n",
encoding="utf-8",
)
values = _load_env_file(str(env_file))
assert values["TAI_HISTORY_DB_USER"] == "from_file"
assert values["TAI_HISTORY_DB_PASSWORD"] == "from_file_pw"
monkeypatch.setenv("TAI_HISTORY_DB_USER", "from_env")
assert _resolve_secret(None, "TAI_HISTORY_DB_USER", values) == "from_file"
assert _resolve_secret("from_cli", "TAI_HISTORY_DB_USER", values) == "from_cli"
def test_man_page_covers_cli_long_options() -> None:
runner = CliRunner()
help_invocations = [
["run", "--help"],
["history", "--help"],
["runbooks", "sync", "--help"],
["runbooks", "list", "--help"],
["runbooks", "add", "--help"],
]
documented = Path("docs/tai.1").read_text(encoding="utf-8")
discovered: set[str] = set()
for args in help_invocations:
result = runner.invoke(app, args)
assert result.exit_code == 0, f"help command failed for: {' '.join(args)}"
discovered.update(re.findall(r"--[a-z0-9][a-z0-9-]*", result.stdout))
discovered.discard("--help")
missing = sorted(option for option in discovered if option not in documented)
assert missing == [], f"Missing options in docs/tai.1: {', '.join(missing)}"

115
tests/test_history_store.py Normal file
View File

@@ -0,0 +1,115 @@
"""Tests for SQLite-backed run history storage."""
from __future__ import annotations
from pathlib import Path
from tai.history_store import RunHistoryStore
def test_history_store_add_and_count(tmp_path) -> None: # type: ignore[no-untyped-def]
store = RunHistoryStore(tmp_path / "history.db")
assert store.count() == 0
payload = {
"schema": "tai.analysis.v1",
"generated_at": "2026-05-11T12:00:00+00:00",
"issue": "sshd failed",
"host": "ssh.archflux.net",
"model": "gemma3:4b",
"collection": {"total": 5, "failed": 1, "succeeded": 4},
"token_usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
"analysis": "Root Cause...",
}
store.add_payload(payload)
assert store.count() == 1
assert store.count(host="ssh.archflux.net") == 1
assert store.count(host="other") == 0
def test_history_store_list_host_sessions(tmp_path) -> None: # type: ignore[no-untyped-def]
store = RunHistoryStore(tmp_path / "history.db")
store.add_payload(
{
"schema": "tai.analysis.v1",
"generated_at": "2026-05-11T12:00:00+00:00",
"issue": "issue one",
"host": "ssh.archflux.net",
"model": "gemma3:4b",
"collection": {"total": 1, "failed": 0, "succeeded": 1},
"token_usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
"analysis": "first",
}
)
store.add_payload(
{
"schema": "tai.analysis.v1",
"generated_at": "2026-05-11T12:05:00+00:00",
"issue": "issue two",
"host": "ssh.archflux.net",
"model": "gemma3:4b",
"collection": {"total": 1, "failed": 0, "succeeded": 1},
"token_usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
"analysis": "second",
}
)
sessions = store.list_host_sessions("ssh.archflux.net", limit=2)
assert len(sessions) == 2
assert sessions[0].issue == "issue two"
assert sessions[1].issue == "issue one"
def test_history_store_list_recent_and_search_keyword(tmp_path) -> None: # type: ignore[no-untyped-def]
store = RunHistoryStore(tmp_path / "history.db")
store.add_payload(
{
"schema": "tai.analysis.v1",
"generated_at": "2026-05-11T13:00:00+00:00",
"issue": "nginx failed",
"host": "web01",
"model": "gemma3:4b",
"collection": {"total": 1, "failed": 0, "succeeded": 1},
"token_usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
"analysis": "nginx config typo",
}
)
store.add_payload(
{
"schema": "tai.analysis.v1",
"generated_at": "2026-05-11T13:10:00+00:00",
"issue": "sshd failed",
"host": "ssh.archflux.net",
"model": "gemma3:4b",
"collection": {"total": 1, "failed": 0, "succeeded": 1},
"token_usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3},
"analysis": "sshd key mismatch",
}
)
recent = store.list_recent(limit=2)
assert len(recent) == 2
assert recent[0].issue == "sshd failed"
matches = store.search_keyword("key", host="ssh.archflux.net", limit=5)
assert len(matches) == 1
assert matches[0].host == "ssh.archflux.net"
def test_history_store_accepts_sqlite_url(tmp_path: Path) -> None:
db_file = tmp_path / "history-url.db"
store = RunHistoryStore(f"sqlite:///{db_file}")
store.add_payload(
{
"schema": "tai.analysis.v1",
"generated_at": "2026-05-11T13:20:00+00:00",
"issue": "test",
"host": "host1",
"model": "gemma3:4b",
"collection": {"total": 1, "failed": 0, "succeeded": 1},
"token_usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
"analysis": "ok",
}
)
assert store.count(host="host1") == 1

View File

@@ -100,6 +100,7 @@ def _make_chromadb_mock() -> MagicMock:
client.get_or_create_collection.return_value = collection
chroma_mod = MagicMock()
chroma_mod.PersistentClient.return_value = client
chroma_mod.HttpClient.return_value = client
return chroma_mod
@@ -251,3 +252,28 @@ def test_runbook_store_sync_single_missing_file_raises(tmp_path: Path) -> None:
store = RunbookStore(tmp_path / "store")
with pytest.raises(FileNotFoundError):
store.sync_single(tmp_path / "missing.md", ai)
def test_runbook_store_remote_url_uses_http_client() -> None:
chroma_mock = _make_chromadb_mock()
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
store = RunbookStore("https://chroma.example.com:8443")
assert store.count() == 0
chroma_mock.HttpClient.assert_called_once_with(host="chroma.example.com", port=8443, ssl=True)
def test_runbook_store_remote_url_uses_http_client_with_basic_auth() -> None:
chroma_mock = _make_chromadb_mock()
with patch.dict("sys.modules", {"chromadb": chroma_mock}):
store = RunbookStore("https://chroma.example.com:8443", username="tai", password="secret")
assert store.count() == 0
args = chroma_mock.HttpClient.call_args.kwargs
assert args["host"] == "chroma.example.com"
assert args["port"] == 8443
assert args["ssl"] is True
assert "headers" in args
assert str(args["headers"].get("Authorization", "")).startswith("Basic ")