mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
test(automations): cross-cutting registries, enums, side-effects + shared fixtures
Top-level tests that span multiple submodules: - test_stores.py (7): the trigger + action registry contracts — register round-trip, unknown type → None (not raise), duplicate registration rejected, defensive snapshot from all_*. - test_definition_types.py (2): params_schema property on both ActionDefinition and TriggerDefinition reflects the Pydantic model. - test_persistence_enums.py (3): exact string values + member sets of AutomationStatus / RunStatus / TriggerType — the postgres-mirrored contract that breaks stored rows if drifted. - test_import_registrations.py (2): the bundled agent_task action and schedule trigger self-register on package import (canary for the side-effect import chain). conftest.py adds isolated_action_registry / isolated_trigger_registry fixtures: snapshot + restore of the module-level _REGISTRY dicts so tests that add their own definitions don't leak across the suite. 14 tests, pure unit.
This commit is contained in:
parent
822940b09e
commit
353755fd73
6 changed files with 292 additions and 0 deletions
0
surfsense_backend/tests/unit/automations/__init__.py
Normal file
0
surfsense_backend/tests/unit/automations/__init__.py
Normal file
39
surfsense_backend/tests/unit/automations/conftest.py
Normal file
39
surfsense_backend/tests/unit/automations/conftest.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
115
surfsense_backend/tests/unit/automations/test_stores.py
Normal file
115
surfsense_backend/tests/unit/automations/test_stores.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue