Files
FabledGrimoire/tests/test_services_backup_job.py
bvandeusenandClaude Opus 5.5 c5eeec471f
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
rename(547): the plugin is grimoire — manifest, MCP key, hooks, env, skills
Step 3 of the Scribe -> Grimoire rename.

- Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key
  `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*.
- hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR
  ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR /
  SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`.
- skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes
  arrive as grimoire-proc-<slug>; moment_actions names them the same way.
- Migration 0124 rewrites stored skill names (rule_moments,
  rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to
  grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing.
- Settings' install commands and MCP-only defaults, the docs and the
  tests follow.
- scripts/check_plugin.py drops the smoke event for a report-check hook
  that has no file in plugin/hooks.

Kept on purpose: the secret-sealing label, and the names that let backups
written as Scribe restore and be counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:52:55 -04:00

224 lines
7.6 KiB
Python

"""The scheduled backup job (services/backup_job.py) and the admin surface
over it. No database: admin settings are an in-memory dict, and the export
and delivery are stand-ins, so what is under test is the job's own contract —
it records every outcome, keeps a bounded history, runs only at its hour,
and never runs twice at once.
"""
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from grimoire.services import backup_destination, backup_job
from grimoire.services.backup_destination import (
BackupConfig, Delivered, DestinationError, apply_changes,
)
NOW = datetime(2026, 10, 15, 2, 0, 0, tzinfo=timezone.utc)
class _Settings(dict):
async def get(self, key, default=""):
return self.get_value(key, default)
def get_value(self, key, default):
return dict.get(self, key, default)
async def set(self, key, value):
self[key] = value
def _patched(settings, deliver=None, config=BackupConfig(enabled=True)):
async def load_config():
return config
async def load_secret():
return (None, False)
async def export():
return {"version": 25, "users": []}
async def delivered(payload, config, secret, at, **kw):
return Delivered("grimoire-20261015T020000Z.tar", 1234, "/backups")
return [
patch.object(backup_job, "get_admin_setting", settings.get),
patch.object(backup_job, "set_admin_setting", settings.set),
patch.object(backup_job, "load_config", load_config),
patch.object(backup_job, "load_secret", load_secret),
patch.object(backup_job, "deliver", deliver or delivered),
patch("grimoire.services.backup.export_full_backup", export),
]
def _run(coro, patches):
for p in patches:
p.start()
try:
return asyncio.run(coro)
finally:
for p in reversed(patches):
p.stop()
def test_a_good_run_is_kept_with_its_file_and_size():
settings = _Settings()
run = _run(backup_job.run_backup("manual"), _patched(settings))
assert run["status"] == "ok"
assert (run["file"], run["size_bytes"], run["trigger"]) == (
"grimoire-20261015T020000Z.tar", 1234, "manual",
)
runs = _run(backup_job.list_runs(), _patched(settings))
assert runs == [run]
def test_a_failed_run_is_kept_with_its_reason_in_words():
async def refuse(*a, **kw):
raise DestinationError("/backups isn't there.")
settings = _Settings()
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=refuse))
assert run["status"] == "failed"
assert run["error"] == "/backups isn't there."
def test_an_unexpected_error_is_a_failed_run_not_a_crash():
async def boom(*a, **kw):
raise RuntimeError("disk on fire")
settings = _Settings()
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=boom))
assert run["status"] == "failed"
assert "disk on fire" in run["error"]
def test_a_run_that_hangs_is_abandoned_at_the_deadline():
async def hang(*a, **kw):
await asyncio.sleep(10)
settings = _Settings()
with patch.object(backup_job, "RUN_DEADLINE", timedelta(seconds=0.05)):
run = _run(backup_job.run_backup("scheduled"), _patched(settings, deliver=hang))
assert run["status"] == "failed"
assert "didn't finish" in run["error"]
assert not backup_job.is_running()
def test_the_history_keeps_only_the_newest_runs():
settings = _Settings()
async def many():
for _ in range(backup_job.KEEP_RUNS + 5):
await backup_job.run_backup("scheduled")
return await backup_job.list_runs()
runs = _run(many(), _patched(settings))
assert len(runs) == backup_job.KEEP_RUNS
assert runs[0]["started_at"] >= runs[-1]["started_at"]
def test_a_second_run_while_one_is_going_is_refused():
started = asyncio.Event()
release = asyncio.Event()
async def slow(*a, **kw):
started.set()
await release.wait()
return Delivered("x.tar", 1, "/backups")
async def both():
first = asyncio.create_task(backup_job.run_backup("manual"))
await started.wait()
with pytest.raises(backup_job.BackupBusy):
await backup_job.run_backup("manual")
release.set()
return await first
run = _run(both(), _patched(_Settings(), deliver=slow))
assert run["status"] == "ok"
@pytest.mark.parametrize("enabled,hour,ran", [
(True, NOW.hour, True),
(True, (NOW.hour + 1) % 24, False),
(False, NOW.hour, False),
])
def test_the_hourly_tick_runs_only_when_on_and_at_its_hour(enabled, hour, ran):
calls = []
async def fake_run(trigger):
calls.append(trigger)
class _Now(datetime):
@classmethod
def now(cls, tz=None):
return NOW
async def config():
return BackupConfig(enabled=enabled, hour=hour)
with patch.object(backup_job, "load_config", config), \
patch.object(backup_job, "run_backup", fake_run), \
patch.object(backup_job, "datetime", _Now):
asyncio.run(backup_job._hourly_check())
assert calls == (["scheduled"] if ran else [])
# ── The page's verdict ───────────────────────────────────────────────────────
def _ok(at):
return {"status": "ok", "finished_at": at.isoformat()}
def test_a_recent_success_is_not_overdue():
s = backup_job.summarise([_ok(NOW - timedelta(hours=3))], enabled=True, now=NOW)
assert s["overdue"] is False
assert s["last_success"]["status"] == "ok"
def test_failures_after_an_old_success_are_overdue():
runs = [{"status": "failed", "finished_at": NOW.isoformat()},
_ok(NOW - timedelta(hours=30))]
s = backup_job.summarise(runs, enabled=True, now=NOW)
assert s["overdue"] is True
assert s["last_run"]["status"] == "failed"
def test_off_is_never_overdue_and_nothing_run_yet_is_not_late():
assert backup_job.summarise([], enabled=False, now=NOW)["overdue"] is False
assert backup_job.summarise([], enabled=True, now=NOW)["overdue"] is False
failed_only = [{"status": "failed", "finished_at": NOW.isoformat()}]
assert backup_job.summarise(failed_only, enabled=True, now=NOW)["overdue"] is True
# ── What an admin sends ──────────────────────────────────────────────────────
def test_changes_apply_on_top_of_the_current_config():
config = apply_changes(BackupConfig(), {"enabled": True, "hour": "5", "s3_secret": "x"})
assert (config.enabled, config.hour, config.destination) == (True, 5, "folder")
@pytest.mark.parametrize("data,said", [
({"hour": 24}, "from 0 to 23"),
({"hour": "two"}, "whole number"),
({"destination": "ftp"}, "folder or an S3 bucket"),
({"enabled": "yes"}, "true or false"),
])
def test_a_typed_mistake_is_refused_with_the_reason(data, said):
with pytest.raises(ValueError, match=said):
apply_changes(BackupConfig(), data)
def test_routes_and_scheduler_are_wired():
from grimoire.app import create_app
from grimoire.routes import admin as admin_routes
for name in ("get_backups", "update_backups", "check_backups_destination", "run_backup_now"):
assert callable(getattr(admin_routes, name))
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert {"/api/admin/backups", "/api/admin/backups/check", "/api/admin/backups/run"} <= rules
assert callable(backup_job.start_backup_scheduler)
assert callable(backup_job.stop_backup_scheduler)
assert backup_destination.SECRET_SETTING == "backup_s3_secret"