feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
CI / lint (push) Successful in 2m53s
CI / security (push) Successful in 2m5s
CI / test (push) Successful in 4m52s
CI / build (push) Successful in 2m5s
CI / e2e (push) Successful in 16m14s

This commit is contained in:
2026-10-04 13:11:25 -04:00
parent fe9f7b49f7
commit 4ce677902a
31 changed files with 2347 additions and 14 deletions
+166
View File
@@ -0,0 +1,166 @@
# tests/test_duplicates.py — Duplicate detection & merge (#166)
"""Tests for backend.services.duplicates, its REST routes and AI tools.
Fusion is destructive: the service takes backups first and the tool/route
require an explicit confirmation (DANGEROUS + ``confirm: true``).
"""
from pathlib import Path
import pytest
from backend.services import duplicates as _duplicates
from backend.services.errors import ServiceError
from backend.tools.api import ToolContext, call_tool
from backend.tools.context import ToolConfirmationRequired
def _ctx(**kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
class TestSimilarity:
def test_identical_texts_score_one(self):
text = "# Titre\nContenu identique avec plusieurs mots significatifs."
assert _duplicates.similarity_score(text, text) == 1.0
def test_different_texts_score_low(self):
a = "# Recette pizza\nFarine, tomate, mozzarella, four à bois."
b = "# Config réseau\nAdresse IP, masque, passerelle, DNS du datacenter."
assert _duplicates.similarity_score(a, b) < 0.5
def test_empty_text_scores_zero(self):
assert _duplicates.similarity_score("", "# Titre\ncontenu") == 0.0
def test_frontmatter_ignored(self):
a = "---\ntitle: A\ntags: [x]\n---\n# Note\nContenu commun significatif ici."
b = "---\ntitle: B\ntags: [y]\n---\n# Note\nContenu commun significatif ici."
assert _duplicates.similarity_score(a, b) > 0.8
class TestFindPairs:
def test_finds_planted_duplicates(self, client, test_vault_dir):
vault = Path(test_vault_dir)
body = "# Rapport mensuel\nVentes en hausse de vingt pour cent ce trimestre."
(vault / "rapport-a.md").write_text(body, encoding="utf-8")
(vault / "rapport-b.md").write_text(body + "\nLigne complémentaire mineure.", encoding="utf-8")
result = _duplicates.find_duplicate_pairs("TestVault", threshold=0.5, limit=20)
assert result["vault"] == "TestVault"
assert result["files_scanned"] >= 2
pair_files = {(p["file_a"], p["file_b"]) for p in result["pairs"]}
assert ("rapport-a.md", "rapport-b.md") in pair_files
def test_threshold_filters(self, client, test_vault_dir):
result = _duplicates.find_duplicate_pairs("TestVault", threshold=1.0, limit=20)
assert result["pairs"] == []
def test_invalid_threshold_rejected(self, client):
with pytest.raises(ServiceError):
_duplicates.find_duplicate_pairs("TestVault", threshold=0.1)
def test_unknown_vault_rejected(self, client):
with pytest.raises(ServiceError):
_duplicates.find_duplicate_pairs("NoSuchVault")
class TestMerge:
def _plant(self, test_vault_dir: str, name: str, content: str) -> None:
Path(test_vault_dir, name).write_text(content, encoding="utf-8")
def test_merge_append(self, client, test_vault_dir):
self._plant(test_vault_dir, "src.md", "# Source\nContenu source unique.")
self._plant(test_vault_dir, "dst.md", "# Cible\nContenu cible unique.")
result = _duplicates.merge_duplicates("TestVault", "src.md", "dst.md", strategy="append")
assert result["strategy"] == "append"
assert result["deleted"] == "src.md"
assert not Path(test_vault_dir, "src.md").exists()
merged = Path(test_vault_dir, "dst.md").read_text(encoding="utf-8")
assert "Contenu cible unique" in merged
assert "Contenu source unique" in merged
def test_merge_prefer_target(self, client, test_vault_dir):
self._plant(test_vault_dir, "old.md", "# Vieux\nAncien contenu.")
self._plant(test_vault_dir, "new.md", "# Neuf\nNouveau contenu.")
result = _duplicates.merge_duplicates("TestVault", "old.md", "new.md", strategy="prefer_target")
assert result["strategy"] == "prefer_target"
assert Path(test_vault_dir, "new.md").read_text(encoding="utf-8") == "# Neuf\nNouveau contenu."
assert not Path(test_vault_dir, "old.md").exists()
def test_merge_same_path_rejected(self, client, test_vault_dir):
self._plant(test_vault_dir, "same.md", "# Same\nContenu.")
with pytest.raises(ServiceError):
_duplicates.merge_duplicates("TestVault", "same.md", "same.md")
def test_merge_missing_source_rejected(self, client, test_vault_dir):
self._plant(test_vault_dir, "exists.md", "# Exists\nContenu.")
with pytest.raises(ServiceError):
_duplicates.merge_duplicates("TestVault", "missing.md", "exists.md")
class TestDuplicatesApi:
def test_list_endpoint(self, client, test_vault_dir):
(Path(test_vault_dir) / "dup1.md").write_text("# Doublon\nTexte commun significatif.", encoding="utf-8")
(Path(test_vault_dir) / "dup2.md").write_text("# Doublon\nTexte commun significatif.", encoding="utf-8")
resp = client.get("/api/duplicates", params={"vault": "TestVault", "threshold": 0.5})
assert resp.status_code == 200, resp.text
assert resp.json()["vault"] == "TestVault"
def test_merge_requires_confirm(self, client, test_vault_dir):
(Path(test_vault_dir) / "m1.md").write_text("# M1\nContenu.", encoding="utf-8")
(Path(test_vault_dir) / "m2.md").write_text("# M2\nContenu.", encoding="utf-8")
resp = client.post(
"/api/duplicates/merge",
json={"vault": "TestVault", "source_path": "m1.md", "target_path": "m2.md"},
)
assert resp.status_code == 400
def test_merge_with_confirm(self, client, test_vault_dir):
(Path(test_vault_dir) / "c1.md").write_text("# C1\nContenu un.", encoding="utf-8")
(Path(test_vault_dir) / "c2.md").write_text("# C2\nContenu deux.", encoding="utf-8")
resp = client.post(
"/api/duplicates/merge",
json={
"vault": "TestVault",
"source_path": "c1.md",
"target_path": "c2.md",
"strategy": "append",
"confirm": True,
},
)
assert resp.status_code == 200, resp.text
assert resp.json()["deleted"] == "c1.md"
class TestDuplicateTools:
def test_find_tool(self, client, test_vault_dir):
(Path(test_vault_dir) / "t1.md").write_text("# Outil\nRecherche de doublons.", encoding="utf-8")
result = call_tool(
"find_duplicates",
_ctx(),
{"vault": "TestVault", "threshold": 0.5, "limit": 10},
)
assert result.ok
assert result.data["vault"] == "TestVault"
def test_merge_tool_requires_confirmation(self, client, test_vault_dir):
(Path(test_vault_dir) / "s1.md").write_text("# S1\nContenu.", encoding="utf-8")
(Path(test_vault_dir) / "s2.md").write_text("# S2\nContenu.", encoding="utf-8")
with pytest.raises(ToolConfirmationRequired):
call_tool(
"merge_duplicate_notes",
_ctx(),
{"vault": "TestVault", "source_path": "s1.md", "target_path": "s2.md"},
)
def test_merge_tool_confirmed(self, client, test_vault_dir):
(Path(test_vault_dir) / "k1.md").write_text("# K1\nContenu k.", encoding="utf-8")
(Path(test_vault_dir) / "k2.md").write_text("# K2\nContenu l.", encoding="utf-8")
result = call_tool(
"merge_duplicate_notes",
_ctx(confirmed=True),
{"vault": "TestVault", "source_path": "k1.md", "target_path": "k2.md", "strategy": "append"},
)
assert result.ok
assert result.data["deleted"] == "k1.md"
+202
View File
@@ -0,0 +1,202 @@
# tests/test_notify_channels.py — External notifications (#168)
"""Tests for backend.notify (Discord, Telegram, SMTP, webhook), its REST
routes and the ``notify_external`` AI tool.
Network calls are mocked: no test ever hits the real Internet (hermetic CI).
"""
import pytest
from backend import notify as _notify
from backend.tools.api import ToolContext, call_tool
@pytest.fixture
def isolated_store(tmp_path, monkeypatch):
"""Redirect the channel + secret stores to a tmp dir."""
channels = tmp_path / "notify_channels.json"
secrets = tmp_path / "notify_secrets.json"
monkeypatch.setattr(_notify, "CHANNELS_FILE", channels)
monkeypatch.setattr(_notify, "SECRETS_FILE", secrets)
monkeypatch.setenv("OBSIGATE_WEBHOOK_ALLOW_PRIVATE", "true")
return tmp_path
def _ctx(**kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
class TestValidation:
def test_unknown_type_rejected(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("x", "slack", {})
def test_discord_requires_url(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("d", "discord", {"webhook_url": "not-a-url"})
def test_telegram_requires_chat(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("t", "telegram", {})
def test_smtp_requires_fields(self, isolated_store):
with pytest.raises(ValueError):
_notify.create_channel("m", "smtp", {"host": "smtp.example.com"})
def test_bad_trigger_falls_back_to_manual(self, isolated_store):
channel = _notify.create_channel(
"w",
"webhook",
{"url": "https://example.com/hook", "triggers": ["nope"]},
)
assert channel["config"]["triggers"] == ["manual"]
class TestCrud:
def test_create_masks_secret(self, isolated_store):
channel = _notify.create_channel(
"disc",
"discord",
{
"webhook_url": "https://discord.com/api/webhooks/123/abcdef-secret-token",
"triggers": ["manual", "schedule_failure"],
},
)
assert channel["has_secret"] is True
assert channel["config"]["webhook_url"] == "***"
# Le secret vit dans le store dédié, pas dans le fichier public.
raw = isolated_store.joinpath("notify_channels.json").read_text(encoding="utf-8")
assert "abcdef-secret-token" not in raw
def test_update_and_delete(self, isolated_store):
channel = _notify.create_channel("w", "webhook", {"url": "https://example.com/a"})
updated = _notify.update_channel(channel["id"], {"enabled": False})
assert updated is not None and updated["enabled"] is False
assert _notify.delete_channel(channel["id"]) is True
assert _notify.delete_channel(channel["id"]) is False
def test_update_unknown_returns_none(self, isolated_store):
assert _notify.update_channel("nope", {"enabled": True}) is None
class TestDispatch:
def test_broadcast_skips_unsubscribed_trigger(self, isolated_store, monkeypatch):
_notify.create_channel("w", "webhook", {"url": "https://example.com/a", "triggers": ["manual"]})
calls: list = []
monkeypatch.setattr(_notify, "send_via_channel", lambda ch, t, m, trig="manual": calls.append(ch["id"]))
results = _notify.broadcast("schedule_failure", "T", "M")
assert results == []
assert calls == []
def test_broadcast_delivers_and_records_failure(self, isolated_store, monkeypatch):
channel = _notify.create_channel(
"w", "webhook", {"url": "https://example.com/a", "triggers": ["manual"]}
)
monkeypatch.setattr(_notify, "send_via_channel", lambda ch, t, m, trig="manual": None)
results = _notify.broadcast("manual", "T", "M")
assert results == [{"channel_id": channel["id"], "ok": True}]
def _boom(ch, t, m, trig="manual"):
raise RuntimeError("down")
monkeypatch.setattr(_notify, "send_via_channel", _boom)
results = _notify.broadcast("manual", "T", "M")
assert results[0]["ok"] is False
assert "down" in results[0]["error"]
def test_smtp_send_uses_smtplib(self, isolated_store, monkeypatch):
_notify.create_channel(
"mail",
"smtp",
{
"host": "smtp.example.com",
"port": 587,
"from_addr": "[email protected]",
"to_addr": "[email protected]",
"username": "a",
"password": "s3cret-pwd",
"triggers": ["manual"],
},
)
sent: dict = {}
class _FakeSMTP:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def starttls(self):
sent["tls"] = True
def login(self, user, pwd):
sent["login"] = (user, pwd)
def send_message(self, msg):
sent["subject"] = msg["Subject"]
monkeypatch.setattr(_notify.smtplib, "SMTP", _FakeSMTP)
results = _notify.broadcast("manual", "Sujet", "Corps")
assert results[0]["ok"] is True
assert sent["tls"] is True
assert sent["login"] == ("a", "s3cret-pwd")
assert "Sujet" in sent["subject"]
class TestNotifyTool:
def test_tool_broadcasts(self, isolated_store, monkeypatch):
monkeypatch.setattr(_notify, "broadcast", lambda trig, t, m: [{"channel_id": "c", "ok": True}])
result = call_tool(
"notify_external",
_ctx(confirmed=True),
{"title": "Hello", "message": "World", "trigger": "manual"},
)
assert result.ok
assert result.data["deliveries"] == [{"channel_id": "c", "ok": True}]
def test_tool_unknown_channel(self, isolated_store):
from backend.tools.context import ToolError
with pytest.raises(ToolError):
call_tool(
"notify_external",
_ctx(confirmed=True),
{"title": "H", "message": "M", "channel_id": "unknown"},
)
class TestNotifyApi:
def test_channels_crud(self, client, isolated_store):
created = client.post(
"/api/notify/channels",
json={"name": "w", "type": "webhook", "config": {"url": "https://example.com/a"}},
)
assert created.status_code == 200, created.text
channel_id = created.json()["id"]
assert created.json()["has_secret"] is False
listed = client.get("/api/notify/channels")
assert listed.status_code == 200
assert any(c["id"] == channel_id for c in listed.json())
patched = client.patch(f"/api/notify/channels/{channel_id}", json={"enabled": False})
assert patched.status_code == 200
assert patched.json()["enabled"] is False
deleted = client.delete(f"/api/notify/channels/{channel_id}")
assert deleted.status_code == 200
def test_create_rejects_bad_type(self, client, isolated_store):
resp = client.post("/api/notify/channels", json={"name": "x", "type": "slack", "config": {}})
assert resp.status_code == 400
def test_test_endpoint_no_channel(self, client, isolated_store):
resp = client.post("/api/notify/test", json={"title": "T", "message": "M"})
assert resp.status_code == 200
assert resp.json()["deliveries"] == []
+198
View File
@@ -0,0 +1,198 @@
# tests/test_scheduler.py — Scheduled tasks, type cron (#170)
"""Tests for backend.scheduler, its REST routes and AI tools.
The file store is redirected to tmp; task actions run against the TestVault
fixture vault (create/append reuse the real mutation services).
"""
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from backend import scheduler as _scheduler
from backend.tools.api import ToolContext, call_tool
@pytest.fixture
def isolated_tasks(tmp_path, monkeypatch):
"""Redirect the task store to a tmp file."""
tasks_file = tmp_path / "scheduled_tasks.json"
monkeypatch.setattr(_scheduler, "TASKS_FILE", tasks_file)
monkeypatch.setattr("backend.notify.CHANNELS_FILE", tmp_path / "notify_channels.json")
monkeypatch.setattr("backend.notify.SECRETS_FILE", tmp_path / "notify_secrets.json")
return tasks_file
def _ctx(**kwargs) -> ToolContext:
user = {"username": "tester", "role": "admin", "vaults": ["*"]}
kwargs.setdefault("audit_enabled", False)
return ToolContext(user=user, **kwargs)
class TestValidation:
def test_unknown_action_rejected(self, isolated_tasks):
with pytest.raises(ValueError):
_scheduler.create_task(
"x",
{"kind": "launch_rocket", "params": {}},
{"kind": "interval_hours", "hours": 1},
)
def test_interval_too_short_rejected(self, isolated_tasks):
with pytest.raises(ValueError):
_scheduler.create_task(
"x",
{"kind": "notify", "params": {"title": "T", "message": "M"}},
{"kind": "interval_hours", "hours": 0.1},
)
def test_bad_daily_time_rejected(self, isolated_tasks):
with pytest.raises(ValueError):
_scheduler.create_task(
"x",
{"kind": "notify", "params": {"title": "T", "message": "M"}},
{"kind": "daily_time", "at": "25:00"},
)
class TestNextRun:
def test_interval_from_now(self, isolated_tasks):
now = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc)
nxt = _scheduler.compute_next_run(
{"schedule": {"kind": "interval_hours", "hours": 2}, "last_run_at": None}, now
)
assert nxt == now + timedelta(hours=2)
def test_daily_tomorrow_when_passed(self, isolated_tasks):
now = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc)
nxt = _scheduler.compute_next_run({"schedule": {"kind": "daily_time", "at": "08:00"}}, now)
assert (nxt - now).total_seconds() == pytest.approx(20 * 3600)
def test_daily_today_when_upcoming(self, isolated_tasks):
now = datetime(2026, 10, 4, 7, 0, tzinfo=timezone.utc)
nxt = _scheduler.compute_next_run({"schedule": {"kind": "daily_time", "at": "08:00"}}, now)
assert (nxt - now).total_seconds() == pytest.approx(3600)
class TestExecution:
def test_create_and_run_append(self, client, test_vault_dir, isolated_tasks):
(Path(test_vault_dir) / "journal.md").write_text("# Journal\n", encoding="utf-8")
task = _scheduler.create_task(
"append",
{"kind": "append_to_file", "params": {"vault": "TestVault", "path": "journal.md", "content": "Ligne auto."}},
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
)
outcome = _scheduler.run_task(task["id"], manual=True)
assert outcome["ok"] is True
assert "Ligne auto." in Path(test_vault_dir, "journal.md").read_text(encoding="utf-8")
stored = _scheduler.get_task(task["id"])
assert stored is not None and stored["last_status"] == "ok"
assert stored["run_count"] == 1
assert stored["enabled"] is False # one-shot consommé
def test_tick_runs_due_task(self, client, test_vault_dir, isolated_tasks):
task = _scheduler.create_task(
"once",
{"kind": "create_file", "params": {"vault": "TestVault", "path": "auto/tache.md", "content": "auto"}},
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
)
outcomes = _scheduler.tick()
assert any(o.get("task_id") == task["id"] and o.get("ok") for o in outcomes)
assert Path(test_vault_dir, "auto", "tache.md").exists()
def test_failure_recorded(self, client, isolated_tasks):
task = _scheduler.create_task(
"fail",
{"kind": "append_to_file", "params": {"vault": "TestVault", "path": "missing/nope.md", "content": "x"}},
{"kind": "once_at", "at": "2020-01-01T00:00:00"},
)
outcome = _scheduler.run_task(task["id"], manual=True)
assert outcome["ok"] is False
stored = _scheduler.get_task(task["id"])
assert stored is not None and stored["last_status"] == "error"
assert stored["last_error"]
def test_run_unknown_raises(self, isolated_tasks):
with pytest.raises(KeyError):
_scheduler.run_task("nope", manual=True)
def test_delete(self, isolated_tasks):
task = _scheduler.create_task(
"del",
{"kind": "notify", "params": {"title": "T", "message": "M"}},
{"kind": "interval_hours", "hours": 24},
)
assert _scheduler.delete_task(task["id"]) is True
assert _scheduler.delete_task(task["id"]) is False
class TestSchedulerApi:
def test_crud_and_run(self, client, test_vault_dir, isolated_tasks):
created = client.post(
"/api/scheduler/tasks",
json={
"name": "t",
"action": {
"kind": "create_file",
"params": {"vault": "TestVault", "path": "sched/api.md", "content": "hello"},
},
"schedule": {"kind": "interval_hours", "hours": 24},
},
)
assert created.status_code == 200, created.text
task_id = created.json()["id"]
listed = client.get("/api/scheduler/tasks")
assert listed.status_code == 200
assert any(t["id"] == task_id for t in listed.json())
run = client.post(f"/api/scheduler/tasks/{task_id}/run")
assert run.status_code == 200, run.text
assert run.json()["ok"] is True
assert Path(test_vault_dir, "sched", "api.md").exists()
deleted = client.delete(f"/api/scheduler/tasks/{task_id}")
assert deleted.status_code == 200
def test_create_rejects_unknown_vault(self, client, isolated_tasks):
resp = client.post(
"/api/scheduler/tasks",
json={
"name": "t",
"action": {"kind": "create_file", "params": {"vault": "NoVault", "path": "x.md"}},
"schedule": {"kind": "interval_hours", "hours": 24},
},
)
assert resp.status_code in (400, 403, 404)
def test_run_unknown_404(self, client, isolated_tasks):
assert client.post("/api/scheduler/tasks/nope/run").status_code == 404
class TestSchedulerTools:
def test_create_list_delete_run(self, client, test_vault_dir, isolated_tasks):
created = call_tool(
"create_scheduled_task",
_ctx(confirmed=True),
{
"name": "tool-task",
"action": {
"kind": "create_file",
"params": {"vault": "TestVault", "path": "sched/tool.md", "content": "hi"},
},
"schedule": {"kind": "interval_hours", "hours": 24},
},
)
assert created.ok
task_id = created.data["id"]
listed = call_tool("list_scheduled_tasks", _ctx(), {})
assert listed.ok
assert any(t["id"] == task_id for t in listed.data)
ran = call_tool("run_scheduled_task_now", _ctx(confirmed=True), {"task_id": task_id})
assert ran.ok and ran.data["ok"] is True
deleted = call_tool("delete_scheduled_task", _ctx(confirmed=True), {"task_id": task_id})
assert deleted.ok