`recommendation` n'avait aucun écrivain : les quatre couches de lecture étaient livrées, mais rien ne produisait de ligne. Le moteur comble ce trou. Le catalogue `REGLES` vit dans `app/services/`, pas dans `ml/` : il lit `alert.type`, `alert.severity`, `alert.value` et `alert.threshold`, sans modèle ni feature, et s'appuie sur deux repositories existants. L'arbitrage avec l'ADR 0005, qui annonçait #38 du côté ML, est tranché par l'ADR 0006. Sept règles, cinq par type d'alerte et deux transverses (sévérité critique, dépassement d'au moins 20 % du seuil), donc une à trois recommandations par alerte. L'idempotence est portée par la base : `create_missing()` insère en `ON CONFLICT DO NOTHING` sur `uq_recommendation_alert_rule`, ce qui supprime la fenêtre entre un contrôle préalable et l'insertion. `rule_reference` devient de ce fait une clé fonctionnelle, d'où le suffixe de version sur chaque référence. Deux déclencheurs : `POST /api/v1/recommendations/generate` réservé `admin`, et `python -m app.cli generate-recommendations` (cible `make recommendations`). Limite connue : aucune source n'alimente `alert` aujourd'hui, ni détection interne (#104) ni ingestion de l'API Mock. La route répond, le rapport reste à zéro, et la chaîne s'allume sans retoucher le moteur le jour où les alertes existent. Tests : 80 unitaires et API verts, plus 6 d'intégration dont l'idempotence jouée contre PostgreSQL. Closes #38
148 lines
4.8 KiB
Python
148 lines
4.8 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app import cli
|
|
from app.schemas.auth import valide_complexite
|
|
|
|
|
|
def test_build_parser_reads_the_create_admin_arguments() -> None:
|
|
arguments = cli.build_parser().parse_args(
|
|
["create-admin", "--email", "admin@enervision.fr", "--generate", "--force"]
|
|
)
|
|
|
|
assert arguments.commande == "create-admin"
|
|
assert arguments.email == "admin@enervision.fr"
|
|
assert arguments.generate is True
|
|
assert arguments.force is True
|
|
|
|
|
|
def test_build_parser_requires_a_subcommand() -> None:
|
|
with pytest.raises(SystemExit):
|
|
cli.build_parser().parse_args([])
|
|
|
|
|
|
def test_build_parser_requires_an_email() -> None:
|
|
with pytest.raises(SystemExit):
|
|
cli.build_parser().parse_args(["create-admin"])
|
|
|
|
|
|
def test_read_password_generates_a_long_secret_when_asked(
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
mot_de_passe = cli.read_password(generate=True)
|
|
|
|
assert len(mot_de_passe) >= cli.LONGUEUR_MOT_DE_PASSE_GENERE
|
|
assert mot_de_passe in capsys.readouterr().out
|
|
valide_complexite(mot_de_passe)
|
|
|
|
|
|
def test_read_password_accepts_two_matching_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
saisies = iter(["Un-mot-de-passe-valide1", "Un-mot-de-passe-valide1"])
|
|
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
|
|
|
assert cli.read_password(generate=False) == "Un-mot-de-passe-valide1"
|
|
|
|
|
|
def test_read_password_refuses_a_password_below_the_minimum_length(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(cli, "getpass", lambda _: "Court1!")
|
|
|
|
with pytest.raises(SystemExit):
|
|
cli.read_password(generate=False)
|
|
|
|
|
|
def test_read_password_refuses_a_password_missing_a_character_class(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(cli, "getpass", lambda _: "un-mot-de-passe-sans-majuscule-ni-chiffre")
|
|
|
|
with pytest.raises(SystemExit):
|
|
cli.read_password(generate=False)
|
|
|
|
|
|
def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
saisies = iter(["Un-mot-de-passe-valide1", "Un-autre-mot-de-passe2"])
|
|
monkeypatch.setattr(cli, "getpass", lambda _: next(saisies))
|
|
|
|
with pytest.raises(SystemExit):
|
|
cli.read_password(generate=False)
|
|
|
|
|
|
def test_build_parser_reads_the_export_openapi_arguments() -> None:
|
|
arguments = cli.build_parser().parse_args(
|
|
["export-openapi", "--output", "ailleurs/contrat.json"]
|
|
)
|
|
|
|
assert arguments.commande == "export-openapi"
|
|
assert arguments.output == "ailleurs/contrat.json"
|
|
|
|
|
|
def test_build_parser_defaults_the_export_to_the_versioned_contract() -> None:
|
|
arguments = cli.build_parser().parse_args(["export-openapi"])
|
|
|
|
assert arguments.output == str(cli.CHEMIN_CONTRAT)
|
|
|
|
|
|
def test_settings_of_the_contract_ignore_the_local_environment(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("APP_API_PREFIX", "/api/v9")
|
|
monkeypatch.setenv("APP_NAME", "API du poste de Johan")
|
|
|
|
settings = cli.settings_du_contrat()
|
|
|
|
assert settings.api_prefix == "/api/v1"
|
|
assert settings.name == "EnerVision API"
|
|
|
|
|
|
def test_export_openapi_writes_a_readable_schema_where_asked(tmp_path: Path) -> None:
|
|
destination = tmp_path / "contrat.json"
|
|
|
|
cli.export_openapi(destination)
|
|
|
|
assert json.loads(destination.read_text(encoding="utf-8"))["openapi"].startswith("3.")
|
|
|
|
|
|
# Piège : `main()` réclamait un mot de passe avant de lire la commande. Sans le branchement,
|
|
# l'export resterait bloqué sur `getpass` et aucune CI ne pourrait le rejouer.
|
|
def test_main_exports_the_contract_without_asking_for_a_password(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
destination = tmp_path / "contrat.json"
|
|
|
|
code = cli.main(["export-openapi", "--output", str(destination)])
|
|
|
|
assert code == 0
|
|
assert destination.exists()
|
|
assert str(destination) in capsys.readouterr().out
|
|
|
|
|
|
def test_build_parser_reads_the_generate_recommendations_arguments() -> None:
|
|
arguments = cli.build_parser().parse_args(["generate-recommendations", "--site-id", "SITE002"])
|
|
|
|
assert arguments.commande == "generate-recommendations"
|
|
assert arguments.site_id == "SITE002"
|
|
|
|
|
|
def test_build_parser_defaults_the_generation_to_every_site() -> None:
|
|
arguments = cli.build_parser().parse_args(["generate-recommendations"])
|
|
|
|
assert arguments.site_id is None
|
|
|
|
|
|
def test_main_generates_the_recommendations_without_asking_for_a_password(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
async def fausse_generation(*, site_id: str | None) -> str:
|
|
return f"génération lancée pour {site_id}"
|
|
|
|
monkeypatch.setattr(cli, "generate_recommendations", fausse_generation)
|
|
|
|
code = cli.main(["generate-recommendations", "--site-id", "SITE002"])
|
|
|
|
assert code == 0
|
|
assert "SITE002" in capsys.readouterr().out
|