"""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