MasterMind/tests/test_mastermind_cli.py
Jarian Cottingham bdb34e038d improve: README, LICENSE, pyproject, tests, sub-question traversal fix
- Process nested subQuestions (previously silently dropped)
- Sanitize phase names for summary filenames (path traversal)
- Proper urllib.request import (was __import__ hack)
- Add README, MIT LICENSE, pyproject (activates CI lint/test/security)
- 19 tests: validation, slug, question tree, Ollama fetch (mocked), end-to-end main()
2026-08-20 23:47:43 +00:00

196 lines
6.0 KiB
Python

"""Tests for mastermind_cli."""
import contextlib
import io
import json
import sys
from pathlib import Path
import pytest
import mastermind_cli as mm
# --- validation -------------------------------------------------------------
def test_validate_project_name_valid():
assert mm.validate_project_name("MyProject") == "MyProject"
assert mm.validate_project_name("abc-123_x") == "abc-123_x"
@pytest.mark.parametrize("bad", ["", "a b", "a/b", "../evil", "a.b", "p'x", "caf\u00e9"])
def test_validate_project_name_invalid(bad):
with pytest.raises(SystemExit):
mm.validate_project_name(bad)
def test_validate_no_traversal_inside(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert mm.validate_no_traversal(Path("sub/dir"), "x") == tmp_path / "sub" / "dir"
def test_validate_no_traversal_escape(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
with pytest.raises(SystemExit):
mm.validate_no_traversal(Path("../../outside"), "x")
# --- slug + question tree ----------------------------------------------------
def test_sanitize_phase_name():
assert mm.sanitize_phase_name("Investigation") == "investigation"
assert mm.sanitize_phase_name("Follow up") == "follow_up"
assert mm.sanitize_phase_name("../../evil") == "evil"
assert mm.sanitize_phase_name(" ") == "phase"
assert mm.sanitize_phase_name("a b/c") == "a_b_c"
def test_iter_question_texts_nested():
tree = [
{"text": "Q1"},
{
"text": "Q2",
"subQuestions": [
{"text": "Q2a"},
{"text": "", "subQuestions": [{"text": "Q2a-1"}]},
],
},
{"text": ""},
]
assert mm.iter_question_texts(tree) == ["Q1", "Q2", "Q2a", "Q2a-1"]
def test_iter_question_texts_empty():
assert mm.iter_question_texts(None) == []
assert mm.iter_question_texts([]) == []
# --- loading -----------------------------------------------------------------
def test_load_questions_ok(tmp_path):
p = tmp_path / "q.json"
p.write_text('[{"phase": "A", "questions": [{"text": "x"}]}]', encoding="utf-8")
assert mm.load_questions(p) == [{"phase": "A", "questions": [{"text": "x"}]}]
def test_load_questions_invalid(tmp_path):
p = tmp_path / "q.json"
p.write_text("not json", encoding="utf-8")
with pytest.raises(SystemExit):
mm.load_questions(p)
# --- fetch_response -----------------------------------------------------------
class _FakeResp:
def __init__(self, payload: dict):
self.payload = payload
def read(self):
return json.dumps(self.payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, *args):
return False
def _fake_urlopen(exc=None):
captured = {}
def _urlopen(req, timeout=None):
captured["headers"] = {k.lower(): v for k, v in req.headers.items()}
captured["payload"] = json.loads(req.data.decode("utf-8"))
if exc is not None:
raise exc
return _FakeResp({"response": "hello world"})
return _urlopen, captured
def test_fetch_response_success(monkeypatch):
_urlopen, captured = _fake_urlopen()
monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen)
out = mm.fetch_response("SYS", "What?", ["CTX"])
assert out == "hello world"
prompt = captured["payload"]["prompt"]
assert "SYS" in prompt and "What?" in prompt and "CTX" in prompt
assert captured["headers"]["content-type"] == "application/json"
assert "authorization" not in captured["headers"]
def test_fetch_response_api_key(monkeypatch):
_urlopen, captured = _fake_urlopen()
monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen)
mm.fetch_response("S", "Q", [], api_key="sekret")
assert captured["headers"]["authorization"] == "Bearer sekret"
def test_fetch_response_error_exits(monkeypatch):
_urlopen, _ = _fake_urlopen(exc=OSError("boom"))
monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen)
with pytest.raises(SystemExit):
mm.fetch_response("S", "Q", [])
# --- end to end ---------------------------------------------------------------
QUESTIONS = [
{
"phase": "Alpha",
"questions": [
{"text": "Q1"},
{"text": "Q2", "subQuestions": [{"text": "Q2a"}]},
],
},
{"phase": "Beta", "questions": [{"text": "Q3"}]},
]
def test_main_end_to_end(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "prompt.txt").write_text("SYSTEM", encoding="utf-8")
(tmp_path / "proposal.txt").write_text("PROPOSAL", encoding="utf-8")
(tmp_path / "idiot.txt").write_text("IDIOT", encoding="utf-8")
qfile = tmp_path / "qs.json"
qfile.write_text(json.dumps(QUESTIONS), encoding="utf-8")
calls = []
def fake_fetch(system_prompt, user_prompt, context, *args, **kwargs):
calls.append((user_prompt, context))
return f"ANSWER-{user_prompt}"
monkeypatch.setattr(mm, "fetch_response", fake_fetch)
argv = [
"mastermind_cli.py",
"--project", "Proj",
"--proposal", "proposal.txt",
"--idiot", "idiot.txt",
"--prompt-file", "prompt.txt",
"--questions-file", str(qfile),
"--output-dir", "out",
]
monkeypatch.setattr(sys, "argv", argv)
with contextlib.redirect_stderr(io.StringIO()):
mm.main()
proj = tmp_path / "out" / "Proj"
assert (proj / "question_001.txt").read_text(encoding="utf-8") == "ANSWER-Q1"
assert (proj / "question_002.txt").read_text(encoding="utf-8") == "ANSWER-Q2"
assert (proj / "question_003.txt").read_text(encoding="utf-8") == "ANSWER-Q2a"
assert (proj / "question_004.txt").read_text(encoding="utf-8") == "ANSWER-Q3"
assert (proj / "alpha_summary.txt").exists()
assert (proj / "beta_summary.txt").exists()
results = json.loads((proj / "experiment_results.json").read_text(encoding="utf-8"))
assert [r["phase"] for r in results] == ["Alpha", "Beta"]
# 4 questions + 2 phase summaries
assert len(calls) == 6