test: close coverage gaps + opt-in integration smoke
Unit-Tests (kein Docker nötig), 130 Tests gesamt:
- config: ${ENV}- und ~-Expansion in hf_home
- prompt_io: _pin_dns (DNS-Rebinding wird abgewiesen, andere Hosts unberührt,
Resolver wird wiederhergestellt)
- actions: do_check (running/healthy, missing), do_start non-dry-run
(Happy-Path + Readiness-Fehler mit --logs), _container_lock Force-Bypass
- cli: --max-tokens <= 0 abgelehnt, --expose/--no-expose
scripts/smoke.sh: opt-in End-to-End-Test gegen echten Docker + llama.cpp-Server
(--api-key-Round-Trip inkl. 401/200, start/check/chat/stream/stop, eigener
Container/Port, Cleanup-Trap).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
24202feee6
commit
f83f36fdfb
6 changed files with 268 additions and 4 deletions
|
|
@ -4,8 +4,12 @@ from types import SimpleNamespace
|
|||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from llamacppctl import actions # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
|
||||
from llamacppctl import actions, http_ops # noqa: E402
|
||||
from llamacppctl.docker_ops import ContainerInfo # noqa: E402
|
||||
from llamacppctl.http_ops import HttpError # noqa: E402
|
||||
from llamacppctl.lock_ops import FileLock, LockError # noqa: E402
|
||||
from llamacppctl.schema import ChatReply, CheckResult # noqa: E402
|
||||
from tests.test_docker_ops import make_cfg # noqa: E402
|
||||
|
||||
|
|
@ -125,3 +129,87 @@ def test_do_stop_passes_force(tmp_path, monkeypatch):
|
|||
monkeypatch.setattr(actions, "stop_container", fake_stop)
|
||||
actions.do_stop(cfg, SimpleNamespace(force=True))
|
||||
assert seen["force"] is True
|
||||
|
||||
|
||||
# --- do_check ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_do_check_running_and_healthy(tmp_path, monkeypatch):
|
||||
cfg = _cfg(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
actions, "inspect_container", lambda n: ContainerInfo(n, "running", "healthy")
|
||||
)
|
||||
monkeypatch.setattr(http_ops, "check_health", lambda c: True)
|
||||
monkeypatch.setattr(http_ops, "check_models", lambda c: True)
|
||||
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("ok", "stop"))
|
||||
result = actions.do_check(cfg, SimpleNamespace(user_prompt="ping", max_tokens=8), SimpleNamespace())
|
||||
assert result.container_running is True
|
||||
assert result.http_ok is True
|
||||
assert result.chat_ok is True
|
||||
assert actions.check_exit_code(result) == actions.EXIT_OK
|
||||
|
||||
|
||||
def test_do_check_missing_container(tmp_path, monkeypatch):
|
||||
cfg = _cfg(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
actions, "inspect_container", lambda n: ContainerInfo(n, "missing", "none")
|
||||
)
|
||||
result = actions.do_check(cfg, SimpleNamespace(user_prompt=None, max_tokens=8), SimpleNamespace())
|
||||
assert result.container_exists is False
|
||||
assert result.container_running is False
|
||||
assert result.http_ok is False
|
||||
assert actions.check_exit_code(result) == actions.EXIT_HTTP_NOT_READY
|
||||
|
||||
|
||||
# --- do_start (non-dry-run) ----------------------------------------------
|
||||
|
||||
|
||||
def _wire_start(monkeypatch, ready: bool):
|
||||
monkeypatch.setattr(actions, "validate_model_path", lambda c: None)
|
||||
monkeypatch.setattr(actions, "start_llama_container", lambda c: "abc123def456")
|
||||
monkeypatch.setattr(actions, "wait_until_ready", lambda c, p: ready)
|
||||
monkeypatch.setattr(actions, "container_logs", lambda n, t=100: "log tail")
|
||||
|
||||
|
||||
def test_do_start_happy_path(tmp_path, monkeypatch, capsys):
|
||||
cfg = _cfg(tmp_path)
|
||||
_wire_start(monkeypatch, ready=True)
|
||||
monkeypatch.setattr(actions, "chat_completion_text", lambda c, p: ChatReply("Servus", "stop"))
|
||||
prompt = SimpleNamespace(user_prompt="hi", max_tokens=2048)
|
||||
rc = actions.do_start(cfg, prompt, SimpleNamespace(dry_run=False, force=False, logs=False))
|
||||
out = capsys.readouterr().out
|
||||
assert rc == actions.EXIT_OK
|
||||
assert "Model ready." in out
|
||||
assert "Servus" in out
|
||||
|
||||
|
||||
def test_do_start_readiness_failure(tmp_path, monkeypatch, capsys):
|
||||
cfg = _cfg(tmp_path)
|
||||
_wire_start(monkeypatch, ready=False)
|
||||
prompt = SimpleNamespace(user_prompt=None, max_tokens=2048)
|
||||
rc = actions.do_start(cfg, prompt, SimpleNamespace(dry_run=False, force=False, logs=True, log_lines=50))
|
||||
err = capsys.readouterr().err
|
||||
assert rc == actions.EXIT_HTTP_NOT_READY
|
||||
assert "did not become ready" in err
|
||||
assert "log tail" in err # --logs printed the tail
|
||||
|
||||
|
||||
# --- _container_lock force bypass ----------------------------------------
|
||||
|
||||
|
||||
def test_container_lock_busy_raises_without_force(tmp_path):
|
||||
cfg = _cfg(tmp_path)
|
||||
with FileLock(cfg.lock_file): # hold the lock
|
||||
with pytest.raises(LockError):
|
||||
with actions._container_lock(cfg, SimpleNamespace(force=False)):
|
||||
pass
|
||||
|
||||
|
||||
def test_container_lock_force_bypasses_busy(tmp_path, capsys):
|
||||
cfg = _cfg(tmp_path)
|
||||
entered = False
|
||||
with FileLock(cfg.lock_file): # hold the lock
|
||||
with actions._container_lock(cfg, SimpleNamespace(force=True)):
|
||||
entered = True
|
||||
assert entered is True
|
||||
assert "busy lock" in capsys.readouterr().err
|
||||
|
|
|
|||
|
|
@ -39,6 +39,22 @@ def test_chat_requires_prompt_source():
|
|||
parse_error(["--chat"])
|
||||
|
||||
|
||||
def test_max_tokens_nonpositive_rejected():
|
||||
parse_error(["--start", "--max-tokens", "0"])
|
||||
parse_error(["--start", "--max-tokens", "-5"])
|
||||
|
||||
|
||||
def test_max_tokens_positive_ok():
|
||||
args = parse(["--start", "--max-tokens", "4096"])
|
||||
assert args.max_tokens == 4096
|
||||
|
||||
|
||||
def test_expose_and_no_expose_flags():
|
||||
assert parse(["--start", "--expose"]).expose is True
|
||||
assert parse(["--start", "--no-expose"]).expose is False
|
||||
assert parse(["--start"]).expose is None # unset => config decides
|
||||
|
||||
|
||||
def test_chat_with_prompt_is_valid():
|
||||
args = parse(["--chat", "-p", "hello"])
|
||||
assert args.chat is True
|
||||
|
|
|
|||
|
|
@ -181,6 +181,22 @@ def test_lock_file_derived_from_container_name(tmp_path):
|
|||
assert str(server_cfg.lock_file) == "/tmp/llamacppctl.test_default.lock"
|
||||
|
||||
|
||||
def test_hf_home_expands_env_var(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LLAMACPPCTL_TEST_HOME", "/data/models")
|
||||
cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ${LLAMACPPCTL_TEST_HOME}/sub")
|
||||
cfg_path = _write_config(tmp_path, cfg)
|
||||
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
|
||||
assert str(server_cfg.hf_home) == "/data/models/sub"
|
||||
|
||||
|
||||
def test_hf_home_expands_tilde(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", "/home/tester")
|
||||
cfg = CONFIG_BASIC.replace("hf_home = /srv/models", "hf_home = ~/models")
|
||||
cfg_path = _write_config(tmp_path, cfg)
|
||||
server_cfg, _ = resolve_effective_config(_parse(["--start", "--config", str(cfg_path)]))
|
||||
assert str(server_cfg.hf_home) == "/home/tester/models"
|
||||
|
||||
|
||||
def test_builtin_defaults_has_container_name():
|
||||
defaults = builtin_defaults()
|
||||
assert defaults["container_name"] == "va_llm"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import pytest
|
|||
|
||||
from llamacppctl.prompt_io import (
|
||||
PromptSourceError,
|
||||
_pin_dns,
|
||||
load_text_url,
|
||||
validate_url_target,
|
||||
)
|
||||
|
|
@ -175,6 +176,41 @@ def test_reject_redirect_by_default(monkeypatch, policy):
|
|||
load_text_url("https://example.org/x.txt", policy)
|
||||
|
||||
|
||||
def test_pin_dns_allows_validated_ip(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"host.example": ["1.2.3.4"]})
|
||||
)
|
||||
with _pin_dns("host.example", ["1.2.3.4"]):
|
||||
assert socket.getaddrinfo("host.example", 443) # not filtered away
|
||||
|
||||
|
||||
def test_pin_dns_rejects_rebound_ip(monkeypatch):
|
||||
# Simulate DNS rebinding: after validation to 1.2.3.4, DNS now returns a
|
||||
# private IP; the pin must refuse it during the actual connect.
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"host.example": ["10.0.0.9"]})
|
||||
)
|
||||
with _pin_dns("host.example", ["1.2.3.4"]):
|
||||
with pytest.raises(socket.gaierror):
|
||||
socket.getaddrinfo("host.example", 443)
|
||||
|
||||
|
||||
def test_pin_dns_does_not_touch_other_hosts(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
socket, "getaddrinfo", _fake_getaddrinfo_factory({"other.example": ["9.9.9.9"]})
|
||||
)
|
||||
with _pin_dns("host.example", ["1.2.3.4"]):
|
||||
assert socket.getaddrinfo("other.example", 443) # unrelated host passes
|
||||
|
||||
|
||||
def test_pin_dns_restores_resolver(monkeypatch):
|
||||
sentinel = _fake_getaddrinfo_factory({"host.example": ["1.2.3.4"]})
|
||||
monkeypatch.setattr(socket, "getaddrinfo", sentinel)
|
||||
with _pin_dns("host.example", ["1.2.3.4"]):
|
||||
pass
|
||||
assert socket.getaddrinfo is sentinel # restored after the context
|
||||
|
||||
|
||||
def test_revalidate_redirect_target(monkeypatch, policy):
|
||||
strict_policy = replace(policy, follow_redirects=True)
|
||||
monkeypatch.setattr(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue