"""Unit tests for devx.ci.validate_spec.""" from pathlib import Path from unittest.mock import patch from click.testing import CliRunner from devx.ci.validate_spec import ( AC_CHECKED_RE, AC_UNCHECKED_RE, REQ_ID_RE, cli, find_spec_file, validate_spec_content, ) VALID_SPEC = """\ # OBL-INFRA-531: Fix sso-bridge role for pip install ## Problem The sso-bridge role uses scripts.sso_bridge.listener but the pip package uses sso_bridge.listener. ## Approach REQ-1: Update molecule verify.yml to use sso_bridge.listener REQ-2: Add infra repo clone task to sso_bridge role ## Test Plan - Run molecule test for sso_bridge role - Verify pip package is installed correctly ## Deploy Plan - Merge PR - Auto-deploy to staging ## Rollback Plan - Revert PR - Re-deploy previous version ## Acceptance Criteria - [x] Molecule test passes with sso_bridge.listener - [x] Infra repo is cloned by sso_bridge role """ SPEC_MISSING_SECTION = """\ # OBL-INFRA-531: Fix sso-bridge ## Problem Something is broken. ## Approach REQ-1: Fix it ## Test Plan Run tests """ SPEC_UNCHECKED_AC = """\ # OBL-INFRA-531: Fix sso-bridge ## Problem Broken. ## Approach REQ-1: Fix it ## Test Plan Run tests ## Deploy Plan Deploy ## Rollback Plan Revert ## Acceptance Criteria - [x] Fixed - [ ] Verified in staging """ SPEC_NO_REQ_IDS = """\ # OBL-INFRA-531: Fix sso-bridge ## Problem Broken. ## Approach Fix it. ## Test Plan Run tests ## Deploy Plan Deploy ## Rollback Plan Revert ## Acceptance Criteria - [x] Fixed """ class TestFindSpecFile: def test_finds_exact_match(self, tmp_path: Path) -> None: specs_dir = tmp_path / "specs" specs_dir.mkdir() (specs_dir / "OBL-INFRA-531.md").write_text("content") result = find_spec_file("OBL-INFRA-531", str(specs_dir)) assert result is not None assert result.name == "OBL-INFRA-531.md" def test_finds_case_insensitive(self, tmp_path: Path) -> None: specs_dir = tmp_path / "specs" specs_dir.mkdir() (specs_dir / "obl-infra-531.md").write_text("content") result = find_spec_file("OBL-INFRA-531", str(specs_dir)) assert result is not None def test_returns_none_when_not_found(self, tmp_path: Path) -> None: specs_dir = tmp_path / "specs" specs_dir.mkdir() result = find_spec_file("OBL-INFRA-999", str(specs_dir)) assert result is None def test_returns_none_when_dir_missing(self, tmp_path: Path) -> None: result = find_spec_file("OBL-INFRA-531", str(tmp_path / "nonexistent")) assert result is None class TestValidateSpecContent: def test_valid_spec_passes(self) -> None: errors = validate_spec_content(VALID_SPEC) assert errors == [] def test_missing_sections(self) -> None: errors = validate_spec_content(SPEC_MISSING_SECTION) assert len(errors) >= 3 # Missing Deploy Plan, Rollback Plan, Acceptance Criteria assert any("Deploy Plan" in e for e in errors) assert any("Rollback Plan" in e for e in errors) assert any("Acceptance Criteria" in e for e in errors) def test_unchecked_ac_fails(self) -> None: errors = validate_spec_content(SPEC_UNCHECKED_AC) assert len(errors) == 1 assert "unchecked" in errors[0].lower() def test_no_req_ids_fails(self) -> None: errors = validate_spec_content(SPEC_NO_REQ_IDS) assert any("REQ-ID" in e for e in errors) def test_empty_content_fails(self) -> None: errors = validate_spec_content("") assert len(errors) >= 2 # Missing sections + no REQ-IDs class TestRegexPatterns: def test_req_id_re_matches(self) -> None: assert REQ_ID_RE.search("REQ-1: Do something") assert REQ_ID_RE.search("REQ-42: Another thing") assert not REQ_ID_RE.search("REQ: no number") def test_ac_checked_re_matches(self) -> None: assert AC_CHECKED_RE.search("- [x] Done") assert AC_CHECKED_RE.search(" - [x] Indented") assert not AC_CHECKED_RE.search("- [ ] Not done") def test_ac_unchecked_re_matches(self) -> None: assert AC_UNCHECKED_RE.search("- [ ] Not done") assert AC_UNCHECKED_RE.search(" - [ ] Indented") assert not AC_UNCHECKED_RE.search("- [x] Done") class TestCli: def test_fails_without_task_id(self) -> None: runner = CliRunner() result = runner.invoke(cli, ["--branch", "no-task-id"]) assert result.exit_code != 0 def test_allow_missing_succeeds_without_task_id(self) -> None: runner = CliRunner() result = runner.invoke(cli, ["--branch", "no-task-id", "--allow-missing"]) assert result.exit_code == 0 assert "WARNING" in result.output def test_fails_when_spec_not_found(self, tmp_path: Path) -> None: runner = CliRunner() with patch("devx.ci.validate_spec.extract_task_id", return_value="OBL-INFRA-999"): result = runner.invoke( cli, ["--branch", "OBL-INFRA-999-test", "--specs-dir", str(tmp_path / "specs")], ) assert result.exit_code != 0 assert "No spec file found" in result.output def test_passes_with_valid_spec(self, tmp_path: Path) -> None: specs_dir = tmp_path / "specs" specs_dir.mkdir() (specs_dir / "OBL-INFRA-531.md").write_text(VALID_SPEC) runner = CliRunner() with patch("devx.ci.validate_spec.extract_task_id", return_value="OBL-INFRA-531"): result = runner.invoke( cli, ["--branch", "OBL-INFRA-531-fix-foo", "--specs-dir", str(specs_dir)], ) assert result.exit_code == 0 assert "Spec validated" in result.output def test_fails_with_unchecked_ac(self, tmp_path: Path) -> None: specs_dir = tmp_path / "specs" specs_dir.mkdir() (specs_dir / "OBL-INFRA-531.md").write_text(SPEC_UNCHECKED_AC) runner = CliRunner() with patch("devx.ci.validate_spec.extract_task_id", return_value="OBL-INFRA-531"): result = runner.invoke( cli, ["--branch", "OBL-INFRA-531-fix-foo", "--specs-dir", str(specs_dir)], ) assert result.exit_code != 0 assert "unchecked" in result.output.lower()