diff --git a/surfsense_backend/tests/unit/automations/__init__.py b/surfsense_backend/tests/unit/automations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/automations/conftest.py b/surfsense_backend/tests/unit/automations/conftest.py new file mode 100644 index 000000000..0fbf03234 --- /dev/null +++ b/surfsense_backend/tests/unit/automations/conftest.py @@ -0,0 +1,39 @@ +"""Shared fixtures for the ``app.automations`` unit-test tree. + +Provides registry isolation: the built-in ``schedule`` trigger and +``agent_task`` action self-register at import time. Tests that register +additional triggers/actions (or assert on the registry contents) must +not leak that state to other tests. These fixtures snapshot and restore +the module-level registry dicts. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from app.automations.actions import store as action_store +from app.automations.triggers import store as trigger_store + + +@pytest.fixture +def isolated_action_registry() -> Iterator[None]: + """Snapshot and restore the action registry around a test.""" + snapshot = dict(action_store._REGISTRY) + try: + yield + finally: + action_store._REGISTRY.clear() + action_store._REGISTRY.update(snapshot) + + +@pytest.fixture +def isolated_trigger_registry() -> Iterator[None]: + """Snapshot and restore the trigger registry around a test.""" + snapshot = dict(trigger_store._REGISTRY) + try: + yield + finally: + trigger_store._REGISTRY.clear() + trigger_store._REGISTRY.update(snapshot) diff --git a/surfsense_backend/tests/unit/automations/test_definition_types.py b/surfsense_backend/tests/unit/automations/test_definition_types.py new file mode 100644 index 000000000..231e4fa97 --- /dev/null +++ b/surfsense_backend/tests/unit/automations/test_definition_types.py @@ -0,0 +1,56 @@ +"""Lock the ``params_schema`` derivation on action + trigger definitions. + +Both definition dataclasses expose ``params_schema`` as the JSON Schema +of their ``params_model``. This is what the registry endpoints surface +to the UI as the "what shape do these params take?" contract. +""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel + +from app.automations.actions.types import ActionDefinition +from app.automations.triggers.types import TriggerDefinition + +pytestmark = pytest.mark.unit + + +class _Topic(BaseModel): + """Model with one required string field — minimal schema fingerprint.""" + + topic: str + + +def test_action_definition_params_schema_reflects_params_model() -> None: + """``ActionDefinition.params_schema`` returns a JSON Schema derived + from the Pydantic ``params_model`` — required fields and types are + visible to clients consuming the registry endpoint.""" + definition = ActionDefinition( + type="t", + name="N", + description="D", + params_model=_Topic, + build_handler=lambda _ctx: (lambda _p: {}), # type: ignore[arg-type,return-value] + ) + + schema = definition.params_schema + + assert schema["type"] == "object" + assert schema["properties"]["topic"]["type"] == "string" + assert "topic" in schema["required"] + + +def test_trigger_definition_params_schema_reflects_params_model() -> None: + """Same JSON-Schema derivation contract on the trigger side.""" + definition = TriggerDefinition( + type="t", + description="D", + params_model=_Topic, + ) + + schema = definition.params_schema + + assert schema["type"] == "object" + assert schema["properties"]["topic"]["type"] == "string" + assert "topic" in schema["required"] diff --git a/surfsense_backend/tests/unit/automations/test_import_registrations.py b/surfsense_backend/tests/unit/automations/test_import_registrations.py new file mode 100644 index 000000000..35b1effa7 --- /dev/null +++ b/surfsense_backend/tests/unit/automations/test_import_registrations.py @@ -0,0 +1,37 @@ +"""Lock the bundled import side-effects. + +Importing ``app.automations`` (the package) registers the v1 bundled +action (``agent_task``) and the v1 bundled trigger (``schedule``). If the +import chain breaks (e.g. someone removes ``from . import definition`` +in a sub-package ``__init__``), the system would silently launch with an +empty registry. These tests are the canary. +""" + +from __future__ import annotations + +import pytest + +import app.automations # noqa: F401 (force the package import + its side-effects) +from app.automations.actions.store import get_action +from app.automations.persistence.enums.trigger_type import TriggerType +from app.automations.triggers.store import get_trigger + +pytestmark = pytest.mark.unit + + +def test_bundled_agent_task_action_is_registered_after_package_import() -> None: + """``agent_task`` — the v1 default action — must be discoverable in + the registry after the package is imported.""" + definition = get_action("agent_task") + + assert definition is not None + assert definition.type == "agent_task" + + +def test_bundled_schedule_trigger_is_registered_after_package_import() -> None: + """``schedule`` — the only v1 trigger — must be discoverable in the + registry after the package is imported.""" + definition = get_trigger(TriggerType.SCHEDULE.value) + + assert definition is not None + assert definition.type == TriggerType.SCHEDULE.value diff --git a/surfsense_backend/tests/unit/automations/test_persistence_enums.py b/surfsense_backend/tests/unit/automations/test_persistence_enums.py new file mode 100644 index 000000000..59703dfc6 --- /dev/null +++ b/surfsense_backend/tests/unit/automations/test_persistence_enums.py @@ -0,0 +1,45 @@ +"""Lock the persistence enum string values + members. + +These enums are mirrored by Postgres enum types, embedded in stored DB +rows, and surfaced in the JSON API. Renaming a value (or removing a +member) silently breaks production data and previously-issued API +responses, so the strings + the set of members are the contract. +""" + +from __future__ import annotations + +import pytest + +from app.automations.persistence.enums.automation_status import AutomationStatus +from app.automations.persistence.enums.run_status import RunStatus +from app.automations.persistence.enums.trigger_type import TriggerType + +pytestmark = pytest.mark.unit + + +def test_automation_status_string_values_are_stable() -> None: + """The exact strings persisted to Postgres and served in API JSON.""" + assert {member.value for member in AutomationStatus} == { + "active", + "paused", + "archived", + } + + +def test_run_status_string_values_are_stable() -> None: + """Run lifecycle states embedded in the ``automation_runs`` table.""" + assert {member.value for member in RunStatus} == { + "pending", + "running", + "succeeded", + "failed", + "cancelled", + "timed_out", + } + + +def test_trigger_type_keeps_manual_member_even_though_unregistered() -> None: + """``MANUAL`` is reserved (mirrors the Postgres enum) but the trigger + store does not register it in v1. The enum must keep both members so + existing DB rows and the schema migration plan stay valid.""" + assert {member.value for member in TriggerType} == {"schedule", "manual"} diff --git a/surfsense_backend/tests/unit/automations/test_stores.py b/surfsense_backend/tests/unit/automations/test_stores.py new file mode 100644 index 000000000..e54062d64 --- /dev/null +++ b/surfsense_backend/tests/unit/automations/test_stores.py @@ -0,0 +1,115 @@ +"""Lock the trigger + action registry contracts. + +Both stores share the same API shape (register/get/all + duplicate-raise), +so they're tested together to keep the contract visible side-by-side. +""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel + +from app.automations.actions.store import ( + get_action, + register_action, +) +from app.automations.actions.types import ActionDefinition +from app.automations.triggers.store import ( + all_triggers, + get_trigger, + register_trigger, +) +from app.automations.triggers.types import TriggerDefinition + +pytestmark = pytest.mark.unit + + +class _Params(BaseModel): + """Empty params model used by test-only registrations.""" + + +def _trigger(type_: str = "test_trigger") -> TriggerDefinition: + return TriggerDefinition(type=type_, description="Test trigger.", params_model=_Params) + + +def _action(type_: str = "test_action") -> ActionDefinition: + return ActionDefinition( + type=type_, + name="Test", + description="Test action.", + params_model=_Params, + build_handler=lambda _ctx: (lambda _p: {}), # type: ignore[arg-type,return-value] + ) + + +def test_register_trigger_then_get_trigger_returns_the_same_definition( + isolated_trigger_registry: None, +) -> None: + """The canonical round-trip: register, look up by type, get the same + definition back. Locks the basic registry contract.""" + definition = _trigger() + register_trigger(definition) + + assert get_trigger("test_trigger") is definition + + +def test_register_action_then_get_action_returns_the_same_definition( + isolated_action_registry: None, +) -> None: + """Same round-trip contract for the action registry.""" + definition = _action() + register_action(definition) + + assert get_action("test_action") is definition + + +def test_get_trigger_returns_none_for_unknown_type( + isolated_trigger_registry: None, +) -> None: + """An unknown type returns ``None`` (not raises). Lets callers like + the dispatcher branch on "is this trigger still registered?" without + try/except.""" + assert get_trigger("never_registered") is None + + +def test_get_action_returns_none_for_unknown_type( + isolated_action_registry: None, +) -> None: + """Same ``None``-not-raise contract on the action side.""" + assert get_action("never_registered") is None + + +def test_register_trigger_rejects_duplicate_type( + isolated_trigger_registry: None, +) -> None: + """Re-registering the same ``type`` raises rather than silently + overwriting. Locks the safety net against accidental double-import + (e.g., circular imports re-running the registration block).""" + register_trigger(_trigger()) + + with pytest.raises(ValueError, match="test_trigger"): + register_trigger(_trigger()) + + +def test_register_action_rejects_duplicate_type( + isolated_action_registry: None, +) -> None: + """Same duplicate-rejection contract on the action side.""" + register_action(_action()) + + with pytest.raises(ValueError, match="test_action"): + register_action(_action()) + + +def test_all_triggers_returns_defensive_snapshot( + isolated_trigger_registry: None, +) -> None: + """``all_triggers()`` returns a copy: mutating the returned dict does + not corrupt the internal registry. Locks the snapshot contract that + UI/listing endpoints rely on.""" + register_trigger(_trigger("snapshot_test")) + + snapshot = all_triggers() + snapshot.pop("snapshot_test") + + assert get_trigger("snapshot_test") is not None \ No newline at end of file