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:
CREDO23 2026-05-28 19:03:55 +02:00
parent 822940b09e
commit 353755fd73
6 changed files with 292 additions and 0 deletions

View 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)

View file

@ -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"]

View file

@ -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

View file

@ -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"}

View 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