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
116 lines
3.5 KiB
Python
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"}
|