Test MCP_Perso

This commit is contained in:
MaksTinyWorkshop
2026-03-31 09:24:06 +02:00
parent 6d56061554
commit 80d9d0a48d
32 changed files with 1593 additions and 1 deletions

View File

View 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

View 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"])

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