mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-04-06 21:41:42 +02:00
Test MCP_Perso
This commit is contained in:
0
mcp/leadtech_bmad_mcp/tests/__init__.py
Normal file
0
mcp/leadtech_bmad_mcp/tests/__init__.py
Normal file
246
mcp/leadtech_bmad_mcp/tests/test_knowledge.py
Normal file
246
mcp/leadtech_bmad_mcp/tests/test_knowledge.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from leadtech_bmad_mcp.knowledge import (
|
||||
_safe_path,
|
||||
list_domain_files,
|
||||
search_knowledge,
|
||||
search_global_docs,
|
||||
read_knowledge_doc,
|
||||
_extract_excerpt,
|
||||
LeadtechPaths,
|
||||
get_paths,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _safe_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_safe_path_valid(tmp_path):
|
||||
result = _safe_path(tmp_path, "sub", "file.md")
|
||||
assert result == (tmp_path / "sub" / "file.md").resolve()
|
||||
|
||||
|
||||
def test_safe_path_traversal_blocked(tmp_path):
|
||||
with pytest.raises(ValueError, match="hors base"):
|
||||
_safe_path(tmp_path, "..", "secret.md")
|
||||
|
||||
|
||||
def test_safe_path_deep_traversal_blocked(tmp_path):
|
||||
with pytest.raises(ValueError, match="hors base"):
|
||||
_safe_path(tmp_path, "sub", "../../etc/passwd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_domain_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_knowledge(tmp_path: Path) -> LeadtechPaths:
|
||||
knowledge = tmp_path / "knowledge"
|
||||
(knowledge / "backend" / "patterns").mkdir(parents=True)
|
||||
(knowledge / "backend" / "risques").mkdir(parents=True)
|
||||
(knowledge / "backend" / "patterns" / "contracts.md").write_text("zod contract schema")
|
||||
(knowledge / "backend" / "patterns" / "auth.md").write_text("jwt token session")
|
||||
(knowledge / "backend" / "risques" / "general.md").write_text("never store passwords")
|
||||
return LeadtechPaths(
|
||||
root=tmp_path,
|
||||
knowledge=knowledge,
|
||||
capitalisation=tmp_path / "95_a_capitaliser.md",
|
||||
projects_conf=tmp_path / "_projects.conf",
|
||||
)
|
||||
|
||||
|
||||
def test_list_domain_files_returns_md_files(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
files = list_domain_files("backend", "patterns")
|
||||
assert len(files) == 2
|
||||
assert all(f.suffix == ".md" for f in files)
|
||||
|
||||
|
||||
def test_list_domain_files_invalid_domain(tmp_path):
|
||||
with pytest.raises(ValueError, match="Domaine invalide"):
|
||||
list_domain_files("unknown_domain", "patterns")
|
||||
|
||||
|
||||
def test_list_domain_files_invalid_bucket(tmp_path):
|
||||
with pytest.raises(ValueError, match="Type invalide"):
|
||||
list_domain_files("backend", "unknown_bucket")
|
||||
|
||||
|
||||
def test_list_domain_files_missing_dir_returns_empty(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
files = list_domain_files("frontend", "patterns") # dir not created
|
||||
assert files == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_knowledge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_search_knowledge_finds_match(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_knowledge("backend", "zod contract")
|
||||
assert len(results) >= 1
|
||||
titles = [r["title"] for r in results]
|
||||
assert "contracts" in titles
|
||||
|
||||
|
||||
def test_search_knowledge_no_match_returns_empty(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_knowledge("backend", "xyzzy_not_existing_keyword_abc")
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_search_knowledge_sorted_by_score(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
# contracts.md contains "zod" once, auth.md contains "session" once
|
||||
# query "zod" should rank contracts first
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_knowledge("backend", "zod")
|
||||
assert results[0]["title"] == "contracts"
|
||||
|
||||
|
||||
def test_search_knowledge_respects_max_items(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_knowledge("backend", "a", max_items=1)
|
||||
assert len(results) <= 1
|
||||
|
||||
|
||||
def test_search_knowledge_single_bucket(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_knowledge("backend", "never", bucket="risques")
|
||||
assert all(r["bucket"] == "risques" for r in results)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_knowledge_doc
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_read_knowledge_doc_returns_content(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
content = read_knowledge_doc("backend", "patterns", "contracts")
|
||||
assert "zod" in content
|
||||
|
||||
|
||||
def test_read_knowledge_doc_not_found_raises(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_knowledge_doc("backend", "patterns", "nonexistent")
|
||||
|
||||
|
||||
def test_read_knowledge_doc_traversal_blocked(tmp_path):
|
||||
paths = _make_knowledge(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
with pytest.raises(ValueError, match="hors base"):
|
||||
read_knowledge_doc("backend", "patterns", "../../etc/passwd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_excerpt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_extract_excerpt_centered_on_token():
|
||||
content = "début " * 30 + "ZOD CONTRACT ici" + " fin" * 30
|
||||
excerpt = _extract_excerpt(content, ["zod"])
|
||||
assert "ZOD" in excerpt.upper()
|
||||
|
||||
|
||||
def test_extract_excerpt_fallback_to_start():
|
||||
content = "a b c d e f g h i j k"
|
||||
excerpt = _extract_excerpt(content, ["xyznotfound"])
|
||||
assert excerpt.startswith("a b")
|
||||
|
||||
|
||||
def test_extract_excerpt_adds_ellipsis_when_truncated():
|
||||
content = "x " * 300
|
||||
excerpt = _extract_excerpt(content, ["x"])
|
||||
assert excerpt.endswith("…")
|
||||
|
||||
|
||||
def test_extract_excerpt_length_bounded():
|
||||
content = "token " * 200
|
||||
excerpt = _extract_excerpt(content, ["token"])
|
||||
# EXCERPT_LENGTH=400 + éventuelles ellipses (2 chars) + début tronqué (80 chars avant)
|
||||
assert len(excerpt) <= 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_global_docs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_global_docs(tmp_path: Path) -> LeadtechPaths:
|
||||
(tmp_path / "40_decisions_et_archi.md").write_text("Ne jamais utiliser SQL Server en LXC Proxmox. Toujours PostgreSQL.")
|
||||
(tmp_path / "90_debug_et_postmortem.md").write_text("Bug session: expiresAt manquant causait des sessions fantômes.")
|
||||
(tmp_path / "10_conventions_redaction.md").write_text("Ne jamais écrire directement dans knowledge/.")
|
||||
return LeadtechPaths(
|
||||
root=tmp_path,
|
||||
knowledge=tmp_path / "knowledge",
|
||||
capitalisation=tmp_path / "95_a_capitaliser.md",
|
||||
projects_conf=tmp_path / "_projects.conf",
|
||||
)
|
||||
|
||||
|
||||
def test_search_global_docs_finds_postmortem(tmp_path):
|
||||
paths = _make_global_docs(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_global_docs("session expiresAt")
|
||||
assert len(results) >= 1
|
||||
assert results[0]["title"] == "debug"
|
||||
|
||||
|
||||
def test_search_global_docs_finds_architecture(tmp_path):
|
||||
paths = _make_global_docs(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_global_docs("PostgreSQL Proxmox")
|
||||
assert len(results) >= 1
|
||||
assert results[0]["title"] == "architecture"
|
||||
|
||||
|
||||
def test_search_global_docs_no_match(tmp_path):
|
||||
paths = _make_global_docs(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_global_docs("xyzzy_not_existing_keyword")
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_search_global_docs_includes_excerpt(tmp_path):
|
||||
paths = _make_global_docs(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_global_docs("PostgreSQL")
|
||||
assert "excerpt" in results[0]
|
||||
assert len(results[0]["excerpt"]) > 0
|
||||
|
||||
|
||||
def test_search_global_docs_missing_file_skipped(tmp_path):
|
||||
# Seulement 40_ créé, les deux autres absents
|
||||
(tmp_path / "40_decisions_et_archi.md").write_text("PostgreSQL recommandé.")
|
||||
paths = LeadtechPaths(
|
||||
root=tmp_path,
|
||||
knowledge=tmp_path / "knowledge",
|
||||
capitalisation=tmp_path / "95_a_capitaliser.md",
|
||||
projects_conf=tmp_path / "_projects.conf",
|
||||
)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
results = search_global_docs("PostgreSQL")
|
||||
assert len(results) == 1
|
||||
assert results[0]["title"] == "architecture"
|
||||
|
||||
|
||||
def test_search_global_docs_respects_max_items(tmp_path):
|
||||
paths = _make_global_docs(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.knowledge.get_paths", return_value=paths):
|
||||
# "dans" apparaît dans les 3 fichiers
|
||||
results = search_global_docs("dans jamais", max_items=1)
|
||||
assert len(results) <= 1
|
||||
240
mcp/leadtech_bmad_mcp/tests/test_server_patterns.py
Normal file
240
mcp/leadtech_bmad_mcp/tests/test_server_patterns.py
Normal file
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Tests des gates de validate_plan et validate_patch.
|
||||
On importe les fonctions directement sans démarrer le serveur MCP.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
def _mock_mcp_module():
|
||||
"""Injecte un faux module mcp pour éviter l'import de la dépendance."""
|
||||
class FastMCP:
|
||||
def __init__(self, **kwargs): pass
|
||||
def tool(self, description="", **k): return lambda f: f
|
||||
def resource(self, path, **k): return lambda f: f
|
||||
def run(self): pass
|
||||
|
||||
mcp_mod = types.ModuleType("mcp")
|
||||
mcp_server = types.ModuleType("mcp.server")
|
||||
mcp_fastmcp = types.ModuleType("mcp.server.fastmcp")
|
||||
mcp_fastmcp.FastMCP = FastMCP
|
||||
sys.modules.setdefault("mcp", mcp_mod)
|
||||
sys.modules.setdefault("mcp.server", mcp_server)
|
||||
sys.modules.setdefault("mcp.server.fastmcp", mcp_fastmcp)
|
||||
|
||||
|
||||
_mock_mcp_module()
|
||||
|
||||
from leadtech_bmad_mcp.server import validate_plan, validate_patch # noqa: E402
|
||||
from leadtech_bmad_mcp.knowledge import LeadtechPaths # noqa: E402
|
||||
|
||||
|
||||
def _fake_paths(tmp_path: Path) -> LeadtechPaths:
|
||||
(tmp_path / "80_bmad").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "knowledge" / "workflow" / "risques").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "80_bmad" / "process_llm_et_parallelisation.md").write_text("")
|
||||
(tmp_path / "knowledge" / "workflow" / "risques" / "story-tracking.md").write_text("")
|
||||
return LeadtechPaths(
|
||||
root=tmp_path,
|
||||
knowledge=tmp_path / "knowledge",
|
||||
capitalisation=tmp_path / "95_a_capitaliser.md",
|
||||
projects_conf=tmp_path / "_projects.conf",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_plan — contracts gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidatePlanContracts:
|
||||
def test_blocks_when_no_contract_reference(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "On va implémenter un service utilisateur.", strict=True)
|
||||
assert result["blocking_issues"]
|
||||
assert any("contrats" in i.lower() for i in result["blocking_issues"])
|
||||
|
||||
def test_passes_with_zod(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "On utilise Zod pour valider les inputs. Tests unitaires inclus.", strict=True)
|
||||
assert not result["blocking_issues"]
|
||||
|
||||
def test_passes_with_z_object(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "Schema: z.object({ id: z.string() }). Unit tests inclus.", strict=True)
|
||||
assert not result["blocking_issues"]
|
||||
|
||||
def test_passes_with_contracts_first(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "Approche contracts-first avec shared_contract. Unit tests inclus.", strict=True)
|
||||
assert not result["blocking_issues"]
|
||||
|
||||
def test_no_block_on_frontend(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("frontend", "On construit un formulaire React. Unit tests inclus.", strict=True)
|
||||
# Pas de blocking sur frontend pour l'absence de Zod
|
||||
contract_blocks = [i for i in result["blocking_issues"] if "contrats" in i.lower()]
|
||||
assert not contract_blocks
|
||||
|
||||
|
||||
class TestValidatePlanRequestId:
|
||||
def test_suggests_requestid_when_error_without_requestid(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "On gère les erreurs HTTP. Zod schema. Tests unitaires.", strict=False)
|
||||
assert any("requestid" in s.lower() or "requestId" in s for s in result["should_do"])
|
||||
|
||||
def test_no_suggestion_when_requestid_present(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "On retourne { error: { code, message, requestId } }. Zod. Tests.", strict=False)
|
||||
assert not any("requestid" in s.lower() for s in result["should_do"])
|
||||
|
||||
def test_requestid_snake_case_accepted(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "Le champ request_id est dans chaque error. Zod. Tests.", strict=False)
|
||||
assert not any("requestid" in s.lower() for s in result["should_do"])
|
||||
|
||||
|
||||
class TestValidatePlanTests:
|
||||
def test_flags_missing_test_strategy(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "On implémente la feature X avec Zod contracts-first.", strict=False)
|
||||
assert any("test" in s.lower() for s in result["must_do"])
|
||||
|
||||
def test_no_flag_when_tests_mentioned(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "Feature X avec Zod. Unit tests et integration tests inclus.", strict=False)
|
||||
assert not any("strategie de test" in s.lower() for s in result["must_do"])
|
||||
|
||||
def test_spec_keyword_accepted(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "Feature X avec Zod. Spec files inclus pour chaque module.", strict=False)
|
||||
assert not any("strategie de test" in s.lower() for s in result["must_do"])
|
||||
|
||||
|
||||
class TestValidatePlanParallel:
|
||||
def test_flags_parallel_without_depends_on(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "Cette story est parallel-safe. Zod. Tests.", strict=False)
|
||||
assert any("parallel" in s.lower() for s in result["red_flags"])
|
||||
|
||||
def test_no_flag_when_depends_on_present(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_plan("backend", "parallel-safe: true. depends-on: story-1. Zod. Tests.", strict=False)
|
||||
assert not any("parallel" in s.lower() for s in result["red_flags"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_patch — backend gates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestValidatePatchSession:
|
||||
def test_blocks_session_without_expires_at(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "UPDATE sessions SET user_id = ? WHERE id = ?")
|
||||
assert any("expiresat" in i.lower() or "expiresat" in i.lower() for i in result["blocking_issues"])
|
||||
|
||||
def test_passes_session_with_expires_at_camel(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "UPDATE sessions SET user_id = ?, expiresAt = ? WHERE id = ?")
|
||||
session_blocks = [i for i in result["blocking_issues"] if "expiresat" in i.lower() or "session" in i.lower()]
|
||||
assert not session_blocks
|
||||
|
||||
def test_passes_session_with_expires_at_snake(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "ALTER TABLE sessions ADD COLUMN expires_at TIMESTAMP;")
|
||||
session_blocks = [i for i in result["blocking_issues"] if "expiresat" in i.lower() or "session" in i.lower()]
|
||||
assert not session_blocks
|
||||
|
||||
|
||||
class TestValidatePatchRequestId:
|
||||
def test_flags_error_without_requestid(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "throw new Error('not found')")
|
||||
assert any("requestid" in s.lower() for s in result["must_do"])
|
||||
|
||||
def test_no_flag_with_requestid_camel(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "return { error: { code: 'NOT_FOUND', message, requestId: uuid() } }")
|
||||
assert not any("requestid" in s.lower() for s in result["must_do"])
|
||||
|
||||
def test_no_flag_with_request_id_snake(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "error_response = { code: 'ERR', request_id: generate() }")
|
||||
assert not any("requestid" in s.lower() for s in result["must_do"])
|
||||
|
||||
|
||||
class TestValidatePatchAuthGuard:
|
||||
def test_flags_guard_without_authguard(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "@UseGuards(RolesGuard)\nasync someMethod() {}")
|
||||
assert any("authguard" in s.lower() or "auth" in s.lower() for s in result["red_flags"])
|
||||
|
||||
def test_no_flag_with_authguard(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "@UseGuards(AuthGuard('jwt'), RolesGuard)\nasync someMethod() {}")
|
||||
assert not any("authguard" in s.lower() for s in result["red_flags"])
|
||||
|
||||
def test_no_flag_with_jwtauthguard(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "@UseGuards(JwtAuthGuard, RolesGuard)\nasync someMethod() {}")
|
||||
assert not any("authguard" in s.lower() for s in result["red_flags"])
|
||||
|
||||
|
||||
class TestValidatePatchTests:
|
||||
def test_suggests_tests_when_no_spec_file(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "const x = 1;", strict=True)
|
||||
assert any("test" in s.lower() for s in result["should_do"])
|
||||
|
||||
def test_no_suggestion_when_describe_block(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "describe('x', () => { it('works', () => {}) })", strict=True)
|
||||
assert not any("test" in s.lower() for s in result["should_do"])
|
||||
|
||||
def test_no_suggestion_when_it_block(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch("backend", "it('does something', () => { expect(x).toBe(1) })", strict=True)
|
||||
assert not any("test" in s.lower() for s in result["should_do"])
|
||||
|
||||
|
||||
class TestValidatePatchBmadOnly:
|
||||
def test_blocks_when_only_bmad_artefacts(self, tmp_path):
|
||||
paths = _fake_paths(tmp_path)
|
||||
with patch("leadtech_bmad_mcp.server.get_paths", return_value=paths):
|
||||
result = validate_patch(
|
||||
"backend",
|
||||
"story update",
|
||||
changed_files=["_bmad-output/story-1.md", "_bmad-output/sprint-status.yaml"],
|
||||
)
|
||||
assert any("artefact" in i.lower() or "bmad" in i.lower() for i in result["blocking_issues"])
|
||||
209
mcp/leadtech_bmad_mcp/tests/test_triage.py
Normal file
209
mcp/leadtech_bmad_mcp/tests/test_triage.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from leadtech_bmad_mcp.triage import (
|
||||
parse_capitalisation_entries,
|
||||
novelty_level,
|
||||
scope_level,
|
||||
CapitalisationEntry,
|
||||
)
|
||||
from leadtech_bmad_mcp.knowledge import LeadtechPaths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_BLOCK = """\
|
||||
2026-03-15 — app-alexandrie
|
||||
|
||||
FILE_UPDATE_PROPOSAL
|
||||
Fichier cible : knowledge/backend/patterns/nestjs.md
|
||||
|
||||
Pourquoi :
|
||||
L'ordre des guards NestJS a causé request.user undefined dans EmailVerifiedGuard.
|
||||
|
||||
Proposition :
|
||||
Toujours enregistrer AuthGuard en premier dans providers[] avant tout guard qui lit request.user.
|
||||
"""
|
||||
|
||||
VALID_BLOCK_2 = """\
|
||||
2026-03-20 — app-foo
|
||||
|
||||
FILE_UPDATE_PROPOSAL
|
||||
Fichier cible : knowledge/frontend/risques/general.md
|
||||
|
||||
Pourquoi :
|
||||
Les useEffect sans deps array causent des boucles infinies.
|
||||
|
||||
Proposition :
|
||||
Toujours spécifier le tableau de dépendances dans useEffect.
|
||||
"""
|
||||
|
||||
SEPARATOR = "\n\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_capitalisation_entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_single_entry():
|
||||
entries = parse_capitalisation_entries(VALID_BLOCK)
|
||||
assert len(entries) == 1
|
||||
e = entries[0]
|
||||
assert "app-alexandrie" in e.header
|
||||
assert e.target == "knowledge/backend/patterns/nestjs.md"
|
||||
assert "guards" in e.why.lower()
|
||||
assert "AuthGuard" in e.proposal
|
||||
|
||||
|
||||
def test_parse_multiple_entries():
|
||||
raw = VALID_BLOCK + SEPARATOR + VALID_BLOCK_2
|
||||
entries = parse_capitalisation_entries(raw)
|
||||
assert len(entries) == 2
|
||||
assert "app-alexandrie" in entries[0].header
|
||||
assert "app-foo" in entries[1].header
|
||||
|
||||
|
||||
def test_parse_empty_returns_empty():
|
||||
entries = parse_capitalisation_entries("")
|
||||
assert entries == []
|
||||
|
||||
|
||||
def test_parse_malformed_no_match():
|
||||
raw = "Ce texte ne contient aucune entrée valide."
|
||||
entries = parse_capitalisation_entries(raw)
|
||||
assert entries == []
|
||||
|
||||
|
||||
def test_parse_preserves_multiline_proposal():
|
||||
raw = """\
|
||||
2026-03-15 — proj
|
||||
|
||||
FILE_UPDATE_PROPOSAL
|
||||
Fichier cible : knowledge/backend/patterns/auth.md
|
||||
|
||||
Pourquoi :
|
||||
Raison courte.
|
||||
|
||||
Proposition :
|
||||
Ligne 1 du pattern.
|
||||
Ligne 2 du pattern.
|
||||
Ligne 3 du pattern.
|
||||
"""
|
||||
entries = parse_capitalisation_entries(raw)
|
||||
assert len(entries) == 1
|
||||
assert "Ligne 2" in entries[0].proposal
|
||||
assert "Ligne 3" in entries[0].proposal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# novelty_level
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_paths_with_target(tmp_path: Path, target_rel: str, content: str) -> LeadtechPaths:
|
||||
target = tmp_path / target_rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return LeadtechPaths(
|
||||
root=tmp_path,
|
||||
knowledge=tmp_path / "knowledge",
|
||||
capitalisation=tmp_path / "95_a_capitaliser.md",
|
||||
projects_conf=tmp_path / "_projects.conf",
|
||||
)
|
||||
|
||||
|
||||
def test_novelty_exact_doublon(tmp_path):
|
||||
proposal = "Toujours enregistrer AuthGuard en premier dans providers[]."
|
||||
paths = _make_paths_with_target(tmp_path, "knowledge/backend/patterns/nestjs.md", proposal)
|
||||
entry = CapitalisationEntry(
|
||||
header="2026-03-15 — proj",
|
||||
target="knowledge/backend/patterns/nestjs.md",
|
||||
why="Raison.",
|
||||
proposal=proposal,
|
||||
)
|
||||
with patch("leadtech_bmad_mcp.triage.get_paths", return_value=paths):
|
||||
assert novelty_level(entry) == "DOUBLON_EXACT"
|
||||
|
||||
|
||||
def test_novelty_semantique_doublon(tmp_path):
|
||||
# Le fichier cible contient déjà la plupart des mots clés de la proposition
|
||||
existing = "toujours enregistrer authguard premier providers avant guard request"
|
||||
paths = _make_paths_with_target(tmp_path, "knowledge/backend/patterns/nestjs.md", existing)
|
||||
entry = CapitalisationEntry(
|
||||
header="2026-03-15 — proj",
|
||||
target="knowledge/backend/patterns/nestjs.md",
|
||||
why="Raison.",
|
||||
proposal="Toujours enregistrer authguard premier providers avant guard request utilisateur",
|
||||
)
|
||||
with patch("leadtech_bmad_mcp.triage.get_paths", return_value=paths):
|
||||
result = novelty_level(entry)
|
||||
assert result == "DOUBLON_SEMANTIQUE"
|
||||
|
||||
|
||||
def test_novelty_nouveau_when_file_missing(tmp_path):
|
||||
paths = LeadtechPaths(
|
||||
root=tmp_path,
|
||||
knowledge=tmp_path / "knowledge",
|
||||
capitalisation=tmp_path / "95_a_capitaliser.md",
|
||||
projects_conf=tmp_path / "_projects.conf",
|
||||
)
|
||||
entry = CapitalisationEntry(
|
||||
header="2026-03-15 — proj",
|
||||
target="knowledge/backend/patterns/nestjs.md",
|
||||
why="Raison.",
|
||||
proposal="Un pattern totalement nouveau sur les migrations Prisma.",
|
||||
)
|
||||
with patch("leadtech_bmad_mcp.triage.get_paths", return_value=paths):
|
||||
assert novelty_level(entry) == "NOUVEAU"
|
||||
|
||||
|
||||
def test_novelty_nouveau_when_different_content(tmp_path):
|
||||
existing = "Ce fichier parle de Stripe webhooks et idempotency."
|
||||
paths = _make_paths_with_target(tmp_path, "knowledge/backend/patterns/stripe.md", existing)
|
||||
entry = CapitalisationEntry(
|
||||
header="2026-03-15 — proj",
|
||||
target="knowledge/backend/patterns/stripe.md",
|
||||
why="Raison.",
|
||||
proposal="Un pattern sur les guards NestJS complètement différent.",
|
||||
)
|
||||
with patch("leadtech_bmad_mcp.triage.get_paths", return_value=paths):
|
||||
assert novelty_level(entry) == "NOUVEAU"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scope_level
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_scope_projet_when_multiple_markers():
|
||||
entry = CapitalisationEntry(
|
||||
header="2026-03-15 — proj",
|
||||
target="knowledge/frontend/patterns/forms.md",
|
||||
why="Ce composant spécifique sur cet écran particulier.",
|
||||
proposal="Le label de ce screen doit être en majuscules.",
|
||||
)
|
||||
assert scope_level(entry) == "PROJET"
|
||||
|
||||
|
||||
def test_scope_global_when_no_markers():
|
||||
entry = CapitalisationEntry(
|
||||
header="2026-03-15 — proj",
|
||||
target="knowledge/backend/patterns/auth.md",
|
||||
why="Pattern de validation JWT applicable à tous les projets.",
|
||||
proposal="Toujours vérifier l'expiration du token côté serveur.",
|
||||
)
|
||||
assert scope_level(entry) == "GLOBAL"
|
||||
|
||||
|
||||
def test_scope_global_when_one_marker_only():
|
||||
# un seul marker → pas suffisant pour PROJET
|
||||
entry = CapitalisationEntry(
|
||||
header="2026-03-15 — proj",
|
||||
target="knowledge/backend/patterns/auth.md",
|
||||
why="Ce route spécifique pose problème.",
|
||||
proposal="Ajouter un middleware de validation générique.",
|
||||
)
|
||||
assert scope_level(entry) == "GLOBAL"
|
||||
Reference in New Issue
Block a user