- 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()
196 lines
6.0 KiB
Python
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
|