feat: agent IA phase 4 — doublons #166, notifications externes #168 (Discord/Telegram/SMTP/webhook), taches planifiees #170
This commit is contained in:
@@ -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"
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user