Files
webmail-client/backend/tests/plugins/test_jobs.py
T
lukasandClaude Opus 4.8 4e077f1a37 M3 plugin platform: SDK + loader, RLS-isolated tables, migrations, jobs
Backend foundation for the plugin platform (SPEC §11), Phases 1-3.

Phase 1 — SDK + loader:
- app/plugins/types.py: frozen Plugin/UiManifest/Slot/Page/JobSpec contract.
- app/plugins/sdk.py: sdk(name) facade — the only app.* import allowed for plugins
  (scoped session, plugin.{name}.* publish, blob, outbox, settings, SSRF http_get, KV, enqueue).
- app/plugins/loader.py: entry-point discovery (webmail.plugins), hook forwarding
  (API + worker), router mounting at /api/plugins/{name}, ui-manifest aggregation.
- plugin_kv core table (migration 0013) + GET /api/plugins/ui-manifest.

Phase 2 — non-removable core plugins + DB-enforced privacy:
- builtin flag (calendar/contacts will be builtin=True; uninstall refuses them).
- RLS per-user isolation on plg_* tables: least-privilege webmail_plugin role
  (migration 0014), plugin_session(user_id) sets LOCAL ROLE + app.current_user_id,
  rls.enable_rls() (ENABLE+FORCE RLS + USING/WITH CHECK policy). Mail untouched.
- Per-plugin Alembic runner (shared env, dedicated version table) + install/uninstall
  --drop-data CLI; skeleton fixture proves install -> run -> uninstall --drop-data
  leaves zero residue (§12 M3 acceptance).

Phase 3 — plugin jobs (§11.2):
- JobSpec registered as arq function plg.{plugin}.{func} + cron (5-field parser);
  singleton=True guarded by a per-(plugin,user) Redis lock; wired onto the shared
  interactive worker.

Gates: ruff, mypy --strict (94 files + tests), full suite 326 passed. RLS isolation,
migration round-trip, and singleton lock covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
2026-07-26 21:42:35 +02:00

116 lines
3.5 KiB
Python

"""Plugin background jobs: registration, cron parsing, singleton lock (SPEC §11.2)."""
from __future__ import annotations
from typing import Any
import pytest
from app.plugins import jobs, loader
from app.plugins.jobs import job_name, parse_cron, plugin_arq_functions, plugin_cron_jobs
from app.plugins.sdk import JobSpec, Plugin, sdk
from app.redis_client import get_redis
async def _noop(ctx: dict[str, Any], **kwargs: Any) -> None:
return None
def test_job_name_is_namespaced() -> None:
assert job_name("calendar", JobSpec(func=_noop)) == "plg.calendar._noop"
def test_parse_cron() -> None:
assert parse_cron("*/15 * * * *") == {
"minute": {0, 15, 30, 45},
"hour": None,
"day": None,
"month": None,
"weekday": None,
}
assert parse_cron("0 9-11 1 * 1,3") == {
"minute": {0},
"hour": {9, 10, 11},
"day": {1},
"month": None,
"weekday": {1, 3},
}
with pytest.raises(ValueError, match="5 fields"):
parse_cron("* * *")
def test_functions_and_cron_registered() -> None:
async def refresh(ctx: dict[str, Any], **kwargs: Any) -> None:
return None
async def poll(ctx: dict[str, Any], **kwargs: Any) -> None:
return None
loader.set_plugins_for_test(
[
Plugin(
name="cal",
version="1",
jobs=[JobSpec(func=refresh), JobSpec(func=poll, schedule="*/5 * * * *")],
)
]
)
fn_names = {f.name for f in plugin_arq_functions()}
assert fn_names == {"plg.cal.refresh", "plg.cal.poll"} # both enqueueable
crons = plugin_cron_jobs()
assert [c.name for c in crons] == ["plg.cal.poll.cron"] # only the scheduled one
async def test_singleton_lock_prevents_overlap() -> None:
calls: list[str] = []
async def work(ctx: dict[str, Any], **kwargs: Any) -> None:
calls.append(str(kwargs.get("user_id")))
plugin = Plugin(name="cal", version="1", jobs=[JobSpec(func=work, singleton=True)])
runner, _ = jobs._make_runner(plugin, plugin.jobs[0])
await runner({}, user_id="u1") # runs; lock acquired then released
assert calls == ["u1"]
# Simulate an instance already holding the lock for u2 -> the run is skipped.
held = jobs._lock_key("cal", "work", "u2")
await get_redis().set(held, b"1", nx=True, ex=60)
await runner({}, user_id="u2")
assert calls == ["u1"] # unchanged
await get_redis().delete(held)
await runner({}, user_id="u2") # lock free now -> runs
assert calls == ["u1", "u2"]
async def test_non_singleton_always_runs() -> None:
calls: list[int] = []
async def work(ctx: dict[str, Any], **kwargs: Any) -> None:
calls.append(1)
plugin = Plugin(name="cal", version="1", jobs=[JobSpec(func=work)])
runner, _ = jobs._make_runner(plugin, plugin.jobs[0])
await runner({}, user_id="u1")
await runner({}, user_id="u1")
assert calls == [1, 1] # no lock, both run
async def test_sdk_enqueue_uses_namespaced_name(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
async def fake_enqueue(name: str, **kwargs: Any) -> None:
captured["name"] = name
captured["kwargs"] = kwargs
monkeypatch.setattr("app.plugins.sdk.enqueue_job", fake_enqueue)
async def refresh(ctx: dict[str, Any], **kwargs: Any) -> None:
return None
await sdk("cal").enqueue(refresh, user_id="u1")
assert captured["name"] == "plg.cal.refresh"
assert captured["kwargs"] == {"user_id": "u1"}