Skip to content

Commit f5a33e8

Browse files
frankbriaTest User
andauthored
test(cli): integration tests for all cf proof commands (#455)
* test(cli): integration tests for all cf proof commands (#455) 17 tests across 5 test classes cover every acceptance criterion: AC1 — TestCapture: creates REQ-0001, persists to ledger, increments IDs, rejects invalid severity/source with exit code 1 AC2 — TestRun: PASS/FAIL exit codes, no-obligations empty path, invalid gate error, _run_gate patched via unittest.mock AC3 — TestWaive: marks waived with expiry and persists to ledger, works without expiry, rejects missing REQ (exit 1), rejects bad date format with descriptive error AC4 — TestStatus: empty workspace message, open count, waived count, expired waiver reverts to open and prints Expired notice AC5 — TestClosedLoop: capture → fail run (exit 1, FAIL in output) → status still open → pass run (exit 0, PASS) → evidence recorded All 17 tests pass. No production code changes required. * fix(tests): address CodeRabbit review on proof CLI tests - ws_with_req fixture: assert capture exit_code == 0 for fast failure on setup errors - test_waive_nonexistent_req: add "not found" message assertion alongside exit code check - test_status_shows_open_count/waived_count: tighten to "Open:"/"Waived:" substrings and verify waive exit_code == 0 before status check - test_status_expired_waiver_reverts_to_open: assert "Open:" present after expiry in addition to "Expired" - test_capture_run_enforced_then_satisfied: assert req.status == ReqStatus.SATISFIED after passing run --------- Co-authored-by: Test User <test@example.com>
1 parent 936d588 commit f5a33e8

1 file changed

Lines changed: 323 additions & 0 deletions

File tree

tests/cli/test_proof_commands.py

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
"""Integration tests for 'cf proof' CLI commands.
2+
3+
Exercises all PROOF9 CLI commands through the Typer CliRunner against a real
4+
SQLite workspace — no mocks except _run_gate (which shells out to pytest/ruff).
5+
6+
AC coverage:
7+
AC1 — cf proof capture creates a REQ and persists it
8+
AC2 — cf proof run evaluates workspace against open REQs
9+
AC3 — cf proof waive marks a REQ waived with expiry
10+
AC4 — cf proof status shows correct summary
11+
AC5 — closed loop: capture → run (fail) → run (pass) → status reflects it
12+
"""
13+
14+
from datetime import date
15+
from pathlib import Path
16+
from unittest.mock import patch
17+
18+
import pytest
19+
from typer.testing import CliRunner
20+
21+
from codeframe.cli.app import app
22+
from codeframe.core.proof import ledger
23+
from codeframe.core.proof.models import ReqStatus
24+
from codeframe.core.workspace import create_or_load_workspace
25+
26+
pytestmark = pytest.mark.v2
27+
28+
runner = CliRunner()
29+
30+
_CAPTURE_ARGS = [
31+
"--title", "Login rejects valid credentials",
32+
"--description", "Auth module returns 401 for correct password after cache flush",
33+
"--where", "src/auth/login.py",
34+
"--severity", "high",
35+
"--source", "qa",
36+
]
37+
38+
# ---------------------------------------------------------------------------
39+
# Fixtures
40+
# ---------------------------------------------------------------------------
41+
42+
43+
@pytest.fixture()
44+
def ws(tmp_path: Path):
45+
"""Initialised workspace — returns (workspace_object, workspace_path)."""
46+
workspace = create_or_load_workspace(tmp_path)
47+
return workspace, tmp_path
48+
49+
50+
@pytest.fixture()
51+
def ws_with_req(ws):
52+
"""Workspace that already has one captured requirement (REQ-0001)."""
53+
workspace, workspace_path = ws
54+
result = runner.invoke(app, ["proof", "capture", "-w", str(workspace_path)] + _CAPTURE_ARGS)
55+
assert result.exit_code == 0, f"Fixture setup failed: {result.output}"
56+
return workspace, workspace_path
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# AC1 — cf proof capture
61+
# ---------------------------------------------------------------------------
62+
63+
64+
class TestCapture:
65+
def test_capture_creates_req_and_persists(self, ws):
66+
"""capture should create REQ-0001, print it, and write it to the DB."""
67+
workspace, workspace_path = ws
68+
result = runner.invoke(
69+
app, ["proof", "capture", "-w", str(workspace_path)] + _CAPTURE_ARGS
70+
)
71+
72+
assert result.exit_code == 0, result.output
73+
assert "REQ-0001" in result.output
74+
75+
# Verify persistence — read straight from ledger, not from output
76+
req = ledger.get_requirement(workspace, "REQ-0001")
77+
assert req is not None
78+
assert req.title == "Login rejects valid credentials"
79+
assert req.status == ReqStatus.OPEN
80+
81+
def test_capture_second_req_increments_id(self, ws_with_req):
82+
"""A second capture should produce REQ-0002."""
83+
workspace, workspace_path = ws_with_req
84+
result = runner.invoke(app, [
85+
"proof", "capture", "-w", str(workspace_path),
86+
"--title", "Second bug",
87+
"--description", "Another issue",
88+
"--where", "src/util.py",
89+
"--severity", "low",
90+
"--source", "dogfooding",
91+
])
92+
assert result.exit_code == 0, result.output
93+
assert "REQ-0002" in result.output
94+
95+
def test_capture_invalid_severity_exits_nonzero(self, ws):
96+
"""Invalid severity should print error and exit 1."""
97+
_, workspace_path = ws
98+
result = runner.invoke(app, [
99+
"proof", "capture", "-w", str(workspace_path),
100+
"--title", "Bug", "--description", "Desc",
101+
"--where", "src/x.py", "--severity", "extreme", "--source", "qa",
102+
])
103+
assert result.exit_code != 0
104+
assert "Invalid severity" in result.output
105+
106+
def test_capture_invalid_source_exits_nonzero(self, ws):
107+
"""Invalid source should print error and exit 1."""
108+
_, workspace_path = ws
109+
result = runner.invoke(app, [
110+
"proof", "capture", "-w", str(workspace_path),
111+
"--title", "Bug", "--description", "Desc",
112+
"--where", "src/x.py", "--severity", "high", "--source", "unknown_source",
113+
])
114+
assert result.exit_code != 0
115+
assert "Invalid source" in result.output
116+
117+
118+
# ---------------------------------------------------------------------------
119+
# AC2 — cf proof run
120+
# ---------------------------------------------------------------------------
121+
122+
123+
class TestRun:
124+
@patch("codeframe.core.proof.runner._run_gate")
125+
def test_run_with_passing_obligations(self, mock_run_gate, ws_with_req):
126+
"""run --full with all gates passing should exit 0 and print PASS."""
127+
mock_run_gate.return_value = (True, "All tests passed")
128+
_, workspace_path = ws_with_req
129+
130+
result = runner.invoke(app, ["proof", "run", "-w", str(workspace_path), "--full"])
131+
132+
assert result.exit_code == 0, result.output
133+
assert "PASS" in result.output
134+
assert "All obligations satisfied" in result.output
135+
136+
@patch("codeframe.core.proof.runner._run_gate")
137+
def test_run_with_failing_obligations(self, mock_run_gate, ws_with_req):
138+
"""run --full with any gate failing should exit 1 and print FAIL."""
139+
mock_run_gate.return_value = (False, "assertion failed")
140+
_, workspace_path = ws_with_req
141+
142+
result = runner.invoke(app, ["proof", "run", "-w", str(workspace_path), "--full"])
143+
144+
assert result.exit_code == 1, result.output
145+
assert "FAIL" in result.output
146+
147+
def test_run_no_requirements_exits_zero(self, ws):
148+
"""run on an empty workspace should exit 0 and say no obligations."""
149+
_, workspace_path = ws
150+
result = runner.invoke(app, ["proof", "run", "-w", str(workspace_path), "--full"])
151+
assert result.exit_code == 0, result.output
152+
assert "No applicable obligations found" in result.output
153+
154+
def test_run_invalid_gate_exits_nonzero(self, ws):
155+
"""run with an unrecognised --gate should exit non-zero and print error."""
156+
_, workspace_path = ws
157+
result = runner.invoke(app, [
158+
"proof", "run", "-w", str(workspace_path), "--gate", "nonexistent",
159+
])
160+
assert result.exit_code != 0
161+
assert "Unknown gate" in result.output
162+
163+
164+
# ---------------------------------------------------------------------------
165+
# AC3 — cf proof waive
166+
# ---------------------------------------------------------------------------
167+
168+
169+
class TestWaive:
170+
def test_waive_marks_req_waived_with_expiry(self, ws_with_req):
171+
"""waive should set status=WAIVED and persist reason + expiry."""
172+
workspace, workspace_path = ws_with_req
173+
174+
result = runner.invoke(app, [
175+
"proof", "waive", "REQ-0001",
176+
"-w", str(workspace_path),
177+
"--reason", "No automated test yet",
178+
"--expires", "2027-01-01",
179+
])
180+
181+
assert result.exit_code == 0, result.output
182+
assert "waived" in result.output.lower()
183+
184+
# Verify persistence
185+
req = ledger.get_requirement(workspace, "REQ-0001")
186+
assert req.status == ReqStatus.WAIVED
187+
assert req.waiver is not None
188+
assert req.waiver.reason == "No automated test yet"
189+
assert req.waiver.expires == date(2027, 1, 1)
190+
191+
def test_waive_without_expiry(self, ws_with_req):
192+
"""waive without --expires should still succeed."""
193+
workspace, workspace_path = ws_with_req
194+
195+
result = runner.invoke(app, [
196+
"proof", "waive", "REQ-0001",
197+
"-w", str(workspace_path),
198+
"--reason", "Accepted risk for Q1",
199+
])
200+
201+
assert result.exit_code == 0, result.output
202+
req = ledger.get_requirement(workspace, "REQ-0001")
203+
assert req.status == ReqStatus.WAIVED
204+
assert req.waiver.expires is None
205+
206+
def test_waive_nonexistent_req_exits_nonzero(self, ws):
207+
"""waive on a REQ that was never captured should exit 1 with not-found message."""
208+
_, workspace_path = ws
209+
result = runner.invoke(app, [
210+
"proof", "waive", "REQ-9999",
211+
"-w", str(workspace_path),
212+
"--reason", "Does not exist",
213+
])
214+
assert result.exit_code == 1
215+
assert "not found" in result.output.lower()
216+
217+
def test_waive_invalid_date_exits_nonzero(self, ws_with_req):
218+
"""waive with a non-ISO expires value should exit 1 and explain format."""
219+
_, workspace_path = ws_with_req
220+
result = runner.invoke(app, [
221+
"proof", "waive", "REQ-0001",
222+
"-w", str(workspace_path),
223+
"--reason", "Bad date",
224+
"--expires", "next-tuesday",
225+
])
226+
assert result.exit_code == 1
227+
assert "Invalid date format" in result.output
228+
229+
230+
# ---------------------------------------------------------------------------
231+
# AC4 — cf proof status
232+
# ---------------------------------------------------------------------------
233+
234+
235+
class TestStatus:
236+
def test_status_empty_workspace(self, ws):
237+
"""status on a fresh workspace should say no requirements."""
238+
_, workspace_path = ws
239+
result = runner.invoke(app, ["proof", "status", "-w", str(workspace_path)])
240+
assert result.exit_code == 0, result.output
241+
assert "No proof requirements" in result.output
242+
243+
def test_status_shows_open_count(self, ws_with_req):
244+
"""status after one capture should show Open: 1 on its summary line."""
245+
_, workspace_path = ws_with_req
246+
result = runner.invoke(app, ["proof", "status", "-w", str(workspace_path)])
247+
assert result.exit_code == 0, result.output
248+
assert "Open:" in result.output
249+
assert "1" in result.output
250+
251+
def test_status_shows_waived_count(self, ws_with_req):
252+
"""status after waiving a REQ should show Waived: 1 on its summary line."""
253+
_, workspace_path = ws_with_req
254+
waive_result = runner.invoke(app, [
255+
"proof", "waive", "REQ-0001", "-w", str(workspace_path),
256+
"--reason", "Accepted",
257+
])
258+
assert waive_result.exit_code == 0, waive_result.output
259+
260+
result = runner.invoke(app, ["proof", "status", "-w", str(workspace_path)])
261+
assert result.exit_code == 0, result.output
262+
assert "Waived:" in result.output
263+
assert "1" in result.output
264+
265+
def test_status_expired_waiver_reverts_to_open(self, ws_with_req):
266+
"""A waiver with a past expiry should be reverted and noted in status."""
267+
workspace, workspace_path = ws_with_req
268+
269+
# Inject waiver with past expiry directly via ledger (not CLI date validation)
270+
from codeframe.core.proof.models import Waiver
271+
past_waiver = Waiver(reason="Old waiver", expires=date(2020, 1, 1), approved_by="test")
272+
ledger.waive_requirement(workspace, "REQ-0001", past_waiver)
273+
274+
result = runner.invoke(app, ["proof", "status", "-w", str(workspace_path)])
275+
assert result.exit_code == 0, result.output
276+
assert "Expired" in result.output
277+
assert "Open:" in result.output
278+
279+
280+
# ---------------------------------------------------------------------------
281+
# AC5 — Closed loop: capture → run (fail) → run (pass) → status reflects it
282+
# ---------------------------------------------------------------------------
283+
284+
285+
class TestClosedLoop:
286+
@patch("codeframe.core.proof.runner._run_gate")
287+
def test_capture_run_enforced_then_satisfied(self, mock_run_gate, ws):
288+
"""Full loop: capture → fail run → pass run → status shows satisfied."""
289+
workspace, workspace_path = ws
290+
291+
# Step 1 — capture
292+
result = runner.invoke(
293+
app, ["proof", "capture", "-w", str(workspace_path)] + _CAPTURE_ARGS
294+
)
295+
assert result.exit_code == 0, result.output
296+
assert "REQ-0001" in result.output
297+
298+
# Step 2 — run with obligations failing
299+
mock_run_gate.return_value = (False, "assertion failed")
300+
result = runner.invoke(app, ["proof", "run", "-w", str(workspace_path), "--full"])
301+
assert result.exit_code == 1, result.output
302+
assert "FAIL" in result.output
303+
assert "REQ-0001" in result.output
304+
305+
# Step 3 — status: still open (run failure doesn't auto-satisfy)
306+
result = runner.invoke(app, ["proof", "status", "-w", str(workspace_path)])
307+
assert result.exit_code == 0, result.output
308+
assert "Open" in result.output
309+
310+
# Step 4 — run with all obligations passing
311+
mock_run_gate.return_value = (True, "all green")
312+
result = runner.invoke(app, ["proof", "run", "-w", str(workspace_path), "--full"])
313+
assert result.exit_code == 0, result.output
314+
assert "PASS" in result.output
315+
assert "All obligations satisfied" in result.output
316+
317+
# Step 5 — verify evidence was recorded and req status is satisfied
318+
evidence = ledger.list_evidence(workspace, "REQ-0001")
319+
assert len(evidence) >= 1
320+
assert any(ev.satisfied for ev in evidence)
321+
322+
req = ledger.get_requirement(workspace, "REQ-0001")
323+
assert req.status == ReqStatus.SATISFIED

0 commit comments

Comments
 (0)