mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
Merge commit '7972901f15' into dev_mod
This commit is contained in:
commit
80daf46fbf
74 changed files with 1681 additions and 234 deletions
|
|
@ -13,7 +13,7 @@ from typing import Any
|
|||
|
||||
import pytest
|
||||
|
||||
from app.automations.actions.agent_task.auto_decide import build_auto_decisions
|
||||
from app.automations.actions.builtin.agent_task.auto_decide import build_auto_decisions
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
|
@ -10,7 +10,9 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from app.automations.actions.agent_task.finalize import extract_final_assistant_message
|
||||
from app.automations.actions.builtin.agent_task.finalize import (
|
||||
extract_final_assistant_message,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
"""Lock the input-validation contract used by ``dispatch_run``.
|
||||
"""Lock the input-validation contract enforced before a run is enqueued.
|
||||
|
||||
``_validate_inputs`` is module-internal by convention (underscore), but it
|
||||
encodes a real behavior contract the rest of the system depends on, and the
|
||||
public alternative (``dispatch_run``) requires a real DB session. Tests
|
||||
target the pure function directly; the contract — not the symbol — is what's
|
||||
locked.
|
||||
``validate_inputs`` is the pure schema check that ``enqueue_run`` runs against
|
||||
merged inputs. ``enqueue_run`` itself needs a real DB session, so tests target
|
||||
this pure function directly; the contract — not the symbol — is what's locked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -12,7 +10,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
|
||||
from app.automations.dispatch.errors import DispatchError
|
||||
from app.automations.dispatch.run import _validate_inputs
|
||||
from app.automations.dispatch.inputs import validate_inputs
|
||||
from app.automations.schemas.definition.envelope import AutomationDefinition
|
||||
from app.automations.schemas.definition.inputs import Inputs
|
||||
from app.automations.schemas.definition.plan_step import PlanStep
|
||||
|
|
@ -42,7 +40,7 @@ def test_validate_inputs_passes_through_when_no_schema_is_declared() -> None:
|
|||
"static_key": "value",
|
||||
}
|
||||
|
||||
assert _validate_inputs(definition, runtime_inputs) == runtime_inputs
|
||||
assert validate_inputs(definition, runtime_inputs) == runtime_inputs
|
||||
|
||||
|
||||
def test_validate_inputs_returns_inputs_when_they_match_declared_schema() -> None:
|
||||
|
|
@ -58,14 +56,13 @@ def test_validate_inputs_returns_inputs_when_they_match_declared_schema() -> Non
|
|||
|
||||
inputs = {"topic": "weekly report"}
|
||||
|
||||
assert _validate_inputs(definition, inputs) == inputs
|
||||
assert validate_inputs(definition, inputs) == inputs
|
||||
|
||||
|
||||
def test_validate_inputs_raises_dispatch_error_when_inputs_violate_schema() -> None:
|
||||
"""Inputs that don't match the declared schema must surface as
|
||||
``DispatchError`` (not the raw ``jsonschema.ValidationError``), so the
|
||||
schedule tick and any other caller can handle one dispatch-domain
|
||||
exception type uniformly."""
|
||||
``DispatchError`` (not the raw ``jsonschema.ValidationError``), so every
|
||||
caller can handle one dispatch-domain exception type uniformly."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"topic": {"type": "string"}},
|
||||
|
|
@ -74,4 +71,4 @@ def test_validate_inputs_raises_dispatch_error_when_inputs_violate_schema() -> N
|
|||
definition = _minimal_definition(inputs=Inputs(schema=schema))
|
||||
|
||||
with pytest.raises(DispatchError):
|
||||
_validate_inputs(definition, {"topic": 42}) # type violates string
|
||||
validate_inputs(definition, {"topic": 42}) # type violates string
|
||||
|
|
@ -39,7 +39,7 @@ def test_run_status_string_values_are_stable() -> None:
|
|||
|
||||
|
||||
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"}
|
||||
"""``schedule`` and ``event`` are registered; ``MANUAL`` is reserved
|
||||
(mirrors the Postgres enum) but the trigger store does not register it.
|
||||
The enum must keep every member so DB rows and migrations stay valid."""
|
||||
assert {member.value for member in TriggerType} == {"schedule", "event", "manual"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
"""The ``event`` trigger self-registers on the triggers store at import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.automations.triggers import get_trigger
|
||||
from app.automations.triggers.builtin.event.params import EventTriggerParams
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_event_trigger_is_registered() -> None:
|
||||
definition = get_trigger("event")
|
||||
|
||||
assert definition is not None
|
||||
assert definition.type == "event"
|
||||
assert definition.params_model is EventTriggerParams
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"""Behavior tests for the ``matches`` filter grammar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.automations.triggers.builtin.event.filter import FilterError, matches
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_empty_filter_matches_any_payload() -> None:
|
||||
assert matches({}, {"document_id": 42, "document_type": "FILE"}) is True
|
||||
assert matches({}, {}) is True
|
||||
|
||||
|
||||
def test_scalar_value_is_implicit_equality() -> None:
|
||||
flt = {"document_type": "FILE"}
|
||||
assert matches(flt, {"document_type": "FILE"}) is True
|
||||
assert matches(flt, {"document_type": "WEBPAGE"}) is False
|
||||
|
||||
|
||||
def test_multiple_fields_are_anded() -> None:
|
||||
flt = {"document_type": "FILE", "search_space_id": 7}
|
||||
assert matches(flt, {"document_type": "FILE", "search_space_id": 7}) is True
|
||||
assert matches(flt, {"document_type": "FILE", "search_space_id": 9}) is False
|
||||
|
||||
|
||||
def test_gt_operator_compares_greater_than() -> None:
|
||||
flt = {"page_count": {"$gt": 10}}
|
||||
assert matches(flt, {"page_count": 20}) is True
|
||||
assert matches(flt, {"page_count": 10}) is False
|
||||
assert matches(flt, {"page_count": 5}) is False
|
||||
|
||||
|
||||
def test_remaining_comparison_operators() -> None:
|
||||
assert matches({"n": {"$gte": 10}}, {"n": 10}) is True
|
||||
assert matches({"n": {"$gte": 10}}, {"n": 9}) is False
|
||||
|
||||
assert matches({"n": {"$lt": 10}}, {"n": 9}) is True
|
||||
assert matches({"n": {"$lt": 10}}, {"n": 10}) is False
|
||||
|
||||
assert matches({"n": {"$lte": 10}}, {"n": 10}) is True
|
||||
assert matches({"n": {"$lte": 10}}, {"n": 11}) is False
|
||||
|
||||
assert matches({"s": {"$eq": "FILE"}}, {"s": "FILE"}) is True
|
||||
assert matches({"s": {"$eq": "FILE"}}, {"s": "WEB"}) is False
|
||||
|
||||
assert matches({"s": {"$ne": "FILE"}}, {"s": "WEB"}) is True
|
||||
assert matches({"s": {"$ne": "FILE"}}, {"s": "FILE"}) is False
|
||||
|
||||
|
||||
def test_multiple_operators_on_one_field_are_anded() -> None:
|
||||
flt = {"n": {"$gte": 10, "$lt": 20}}
|
||||
assert matches(flt, {"n": 15}) is True
|
||||
assert matches(flt, {"n": 10}) is True
|
||||
assert matches(flt, {"n": 20}) is False
|
||||
assert matches(flt, {"n": 5}) is False
|
||||
|
||||
|
||||
def test_in_and_nin_membership_operators() -> None:
|
||||
flt_in = {"document_type": {"$in": ["FILE", "WEBPAGE"]}}
|
||||
assert matches(flt_in, {"document_type": "FILE"}) is True
|
||||
assert matches(flt_in, {"document_type": "SLACK"}) is False
|
||||
|
||||
flt_nin = {"document_type": {"$nin": ["FILE", "WEBPAGE"]}}
|
||||
assert matches(flt_nin, {"document_type": "SLACK"}) is True
|
||||
assert matches(flt_nin, {"document_type": "FILE"}) is False
|
||||
|
||||
|
||||
def test_or_matches_when_any_branch_holds() -> None:
|
||||
flt = {"$or": [{"document_type": "FILE"}, {"document_type": "WEBPAGE"}]}
|
||||
assert matches(flt, {"document_type": "WEBPAGE"}) is True
|
||||
assert matches(flt, {"document_type": "SLACK"}) is False
|
||||
|
||||
|
||||
def test_and_matches_when_every_branch_holds() -> None:
|
||||
flt = {"$and": [{"n": {"$gt": 5}}, {"n": {"$lt": 10}}]}
|
||||
assert matches(flt, {"n": 7}) is True
|
||||
assert matches(flt, {"n": 12}) is False
|
||||
|
||||
|
||||
def test_not_inverts_its_subexpression() -> None:
|
||||
flt = {"$not": {"document_type": "FILE"}}
|
||||
assert matches(flt, {"document_type": "WEBPAGE"}) is True
|
||||
assert matches(flt, {"document_type": "FILE"}) is False
|
||||
|
||||
|
||||
def test_missing_field_never_matches_and_never_raises() -> None:
|
||||
# Conservative: an absent field fails the constraint, and comparisons must
|
||||
# not raise on the missing value — including $ne (absence isn't "not equal").
|
||||
assert matches({"document_type": "FILE"}, {}) is False
|
||||
assert matches({"page_count": {"$gt": 5}}, {}) is False
|
||||
assert matches({"document_type": {"$in": ["FILE"]}}, {}) is False
|
||||
assert matches({"document_type": {"$ne": "FILE"}}, {}) is False
|
||||
|
||||
|
||||
def test_logical_operators_compose_with_fields() -> None:
|
||||
flt = {
|
||||
"search_space_id": 7,
|
||||
"$or": [{"document_type": "FILE"}, {"document_type": "WEBPAGE"}],
|
||||
}
|
||||
assert matches(flt, {"search_space_id": 7, "document_type": "FILE"}) is True
|
||||
assert matches(flt, {"search_space_id": 9, "document_type": "FILE"}) is False
|
||||
assert matches(flt, {"search_space_id": 7, "document_type": "SLACK"}) is False
|
||||
|
||||
|
||||
def test_unknown_field_operator_raises_filter_error() -> None:
|
||||
with pytest.raises(FilterError):
|
||||
matches({"n": {"$regex": "x"}}, {"n": "xyz"})
|
||||
|
||||
|
||||
def test_unknown_logical_operator_raises_filter_error() -> None:
|
||||
with pytest.raises(FilterError):
|
||||
matches({"$nor": [{"document_type": "FILE"}]}, {"document_type": "FILE"})
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""An event hands its payload + metadata to the run as inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.automations.triggers.builtin.event.inputs import event_runtime_inputs
|
||||
from app.event_bus import Event
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_runtime_inputs_flatten_payload_with_event_metadata() -> None:
|
||||
event = Event(
|
||||
event_type="document.indexed",
|
||||
payload={"document_id": 42, "document_type": "FILE"},
|
||||
search_space_id=7,
|
||||
)
|
||||
|
||||
inputs = event_runtime_inputs(event)
|
||||
|
||||
assert inputs["document_id"] == 42
|
||||
assert inputs["document_type"] == "FILE"
|
||||
assert inputs["event_type"] == "document.indexed"
|
||||
assert inputs["event_id"] == event.event_id
|
||||
assert inputs["occurred_at"] == event.occurred_at.isoformat()
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"""Which triggers an event fires: event_type equality + filter match."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.automations.triggers.builtin.event.match import trigger_matches_event
|
||||
from app.event_bus import Event
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _event(event_type: str = "document.indexed", **payload) -> Event:
|
||||
return Event(event_type=event_type, payload=payload, search_space_id=7)
|
||||
|
||||
|
||||
def test_matches_when_event_type_equal_and_filter_passes() -> None:
|
||||
params = {"event_type": "document.indexed", "filter": {"document_type": "FILE"}}
|
||||
assert trigger_matches_event(params, _event(document_type="FILE")) is True
|
||||
|
||||
|
||||
def test_no_match_when_event_type_differs() -> None:
|
||||
params = {"event_type": "document.indexed", "filter": {}}
|
||||
assert trigger_matches_event(params, _event("podcast.generated")) is False
|
||||
|
||||
|
||||
def test_no_match_when_filter_rejects_payload() -> None:
|
||||
params = {"event_type": "document.indexed", "filter": {"document_type": "FILE"}}
|
||||
assert trigger_matches_event(params, _event(document_type="WEBPAGE")) is False
|
||||
|
||||
|
||||
def test_empty_filter_matches_any_payload_of_that_type() -> None:
|
||||
params = {"event_type": "document.indexed", "filter": {}}
|
||||
assert trigger_matches_event(params, _event(document_type="ANYTHING")) is True
|
||||
|
||||
|
||||
def test_missing_filter_key_is_treated_as_empty() -> None:
|
||||
params = {"event_type": "document.indexed"}
|
||||
assert trigger_matches_event(params, _event(document_type="X")) is True
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""``EventTriggerParams`` contract: an event_type to listen for + an optional filter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.automations.triggers.builtin.event.params import EventTriggerParams
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_accepts_event_type_and_filter() -> None:
|
||||
params = EventTriggerParams(
|
||||
event_type="document.indexed",
|
||||
filter={"document_type": "FILE"},
|
||||
)
|
||||
|
||||
assert params.event_type == "document.indexed"
|
||||
assert params.filter == {"document_type": "FILE"}
|
||||
|
||||
|
||||
def test_filter_defaults_to_empty() -> None:
|
||||
params = EventTriggerParams(event_type="document.indexed")
|
||||
|
||||
assert params.filter == {}
|
||||
|
||||
|
||||
def test_event_type_is_required() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
EventTriggerParams(filter={"x": 1})
|
||||
|
||||
|
||||
def test_event_type_must_not_be_blank() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
EventTriggerParams(event_type="")
|
||||
|
||||
|
||||
def test_extra_keys_are_forbidden() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
EventTriggerParams(event_type="document.indexed", typo=True)
|
||||
|
|
@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
|||
|
||||
import pytest
|
||||
|
||||
from app.automations.triggers.schedule.cron import (
|
||||
from app.automations.triggers.builtin.schedule.cron import (
|
||||
InvalidCronError,
|
||||
compute_next_fire_at,
|
||||
validate_cron,
|
||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.automations.triggers.schedule.params import ScheduleTriggerParams
|
||||
from app.automations.triggers.builtin.schedule.params import ScheduleTriggerParams
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
0
surfsense_backend/tests/unit/event_bus/__init__.py
Normal file
0
surfsense_backend/tests/unit/event_bus/__init__.py
Normal file
25
surfsense_backend/tests/unit/event_bus/conftest.py
Normal file
25
surfsense_backend/tests/unit/event_bus/conftest.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Shared fixtures for the ``app.event_bus`` unit-test tree.
|
||||
|
||||
The event-type catalog is a module-level registry populated at import. Tests
|
||||
that register their own event types (or assert on registry contents) snapshot
|
||||
and restore it so state never leaks between tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from app.event_bus.catalog import catalog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_event_catalog() -> Iterator[None]:
|
||||
"""Snapshot and restore the event-type catalog around a test."""
|
||||
snapshot = dict(catalog._registry)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
catalog._registry.clear()
|
||||
catalog._registry.update(snapshot)
|
||||
181
surfsense_backend/tests/unit/event_bus/test_bus.py
Normal file
181
surfsense_backend/tests/unit/event_bus/test_bus.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""``EventBus`` contract: subscribe, publish (stamp + fan out), dispatch.
|
||||
|
||||
Each test uses a fresh ``EventBus`` — no shared global state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.event_bus import Event, EventBus
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _event() -> Event:
|
||||
return Event(event_type="x.happened", payload={"k": "v"}, search_space_id=1)
|
||||
|
||||
|
||||
async def _noop(_event: Event) -> None:
|
||||
return None
|
||||
|
||||
|
||||
async def _other(_event: Event) -> None:
|
||||
return None
|
||||
|
||||
|
||||
# --- registry -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_subscribe_then_subscribers_returns_the_handler() -> None:
|
||||
bus = EventBus()
|
||||
bus.subscribe(_noop)
|
||||
|
||||
assert _noop in bus.subscribers()
|
||||
|
||||
|
||||
def test_subscribe_is_idempotent_for_the_same_handler() -> None:
|
||||
"""Registering the same handler twice must not make it fire twice."""
|
||||
bus = EventBus()
|
||||
bus.subscribe(_noop)
|
||||
bus.subscribe(_noop)
|
||||
|
||||
assert bus.subscribers().count(_noop) == 1
|
||||
|
||||
|
||||
def test_distinct_handlers_both_register() -> None:
|
||||
bus = EventBus()
|
||||
bus.subscribe(_noop)
|
||||
bus.subscribe(_other)
|
||||
|
||||
registered = bus.subscribers()
|
||||
assert _noop in registered
|
||||
assert _other in registered
|
||||
|
||||
|
||||
def test_subscribers_returns_a_defensive_snapshot() -> None:
|
||||
"""Mutating the returned list must not corrupt the registry."""
|
||||
bus = EventBus()
|
||||
bus.subscribe(_noop)
|
||||
|
||||
snapshot = bus.subscribers()
|
||||
snapshot.clear()
|
||||
|
||||
assert _noop in bus.subscribers()
|
||||
|
||||
|
||||
def test_subscribe_returns_handler_so_it_can_be_used_as_a_decorator() -> None:
|
||||
bus = EventBus()
|
||||
returned = bus.subscribe(_other)
|
||||
|
||||
assert returned is _other
|
||||
|
||||
|
||||
def test_two_buses_do_not_share_subscribers() -> None:
|
||||
"""The registry is per-instance, not global."""
|
||||
a = EventBus()
|
||||
b = EventBus()
|
||||
a.subscribe(_noop)
|
||||
|
||||
assert _noop in a.subscribers()
|
||||
assert _noop not in b.subscribers()
|
||||
|
||||
|
||||
# --- dispatch -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_dispatch_delivers_event_to_every_subscriber() -> None:
|
||||
bus = EventBus()
|
||||
seen: list[tuple[str, Event]] = []
|
||||
|
||||
async def first(event: Event) -> None:
|
||||
seen.append(("first", event))
|
||||
|
||||
async def second(event: Event) -> None:
|
||||
seen.append(("second", event))
|
||||
|
||||
bus.subscribe(first)
|
||||
bus.subscribe(second)
|
||||
|
||||
event = _event()
|
||||
await bus.dispatch(event)
|
||||
|
||||
assert ("first", event) in seen
|
||||
assert ("second", event) in seen
|
||||
|
||||
|
||||
async def test_dispatch_isolates_a_failing_subscriber() -> None:
|
||||
"""A subscriber that raises must not stop a healthy one from running."""
|
||||
bus = EventBus()
|
||||
healthy_ran = False
|
||||
|
||||
async def boom(_event: Event) -> None:
|
||||
raise RuntimeError("subscriber blew up")
|
||||
|
||||
async def healthy(_event: Event) -> None:
|
||||
nonlocal healthy_ran
|
||||
healthy_ran = True
|
||||
|
||||
bus.subscribe(boom)
|
||||
bus.subscribe(healthy)
|
||||
|
||||
await bus.dispatch(_event())
|
||||
|
||||
assert healthy_ran is True
|
||||
|
||||
|
||||
async def test_dispatch_never_propagates_subscriber_errors() -> None:
|
||||
"""``dispatch`` itself must not raise even if every subscriber fails."""
|
||||
bus = EventBus()
|
||||
|
||||
async def boom(_event: Event) -> None:
|
||||
raise ValueError("nope")
|
||||
|
||||
bus.subscribe(boom)
|
||||
|
||||
await bus.dispatch(_event()) # must not raise
|
||||
|
||||
|
||||
async def test_dispatch_with_no_subscribers_is_a_noop() -> None:
|
||||
bus = EventBus()
|
||||
await bus.dispatch(_event()) # must not raise
|
||||
|
||||
|
||||
# --- publish --------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_publish_builds_a_stamped_event_and_fans_it_out() -> None:
|
||||
bus = EventBus()
|
||||
received: list[Event] = []
|
||||
|
||||
async def handler(event: Event) -> None:
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe(handler)
|
||||
await bus.publish("document.indexed", {"document_id": 42}, search_space_id=7)
|
||||
|
||||
assert len(received) == 1
|
||||
event = received[0]
|
||||
assert event.event_type == "document.indexed"
|
||||
assert event.payload == {"document_id": 42}
|
||||
assert event.search_space_id == 7
|
||||
# Engine-stamped identity/time on the way through.
|
||||
assert event.event_id
|
||||
assert event.occurred_at
|
||||
|
||||
|
||||
async def test_publish_defaults_payload_to_empty_dict() -> None:
|
||||
bus = EventBus()
|
||||
received: list[Event] = []
|
||||
|
||||
async def handler(event: Event) -> None:
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe(handler)
|
||||
await bus.publish("x.happened", search_space_id=1)
|
||||
|
||||
assert received[0].payload == {}
|
||||
|
||||
|
||||
async def test_publish_with_no_subscribers_is_a_noop() -> None:
|
||||
await EventBus().publish("x.happened", search_space_id=1) # must not raise
|
||||
73
surfsense_backend/tests/unit/event_bus/test_catalog.py
Normal file
73
surfsense_backend/tests/unit/event_bus/test_catalog.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""EventCatalog contract: register, look up, snapshot, derive schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.event_bus.catalog import EventCatalog, EventType
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _SamplePayload(BaseModel):
|
||||
document_id: int
|
||||
|
||||
|
||||
def _event_type(type_: str = "test.thing") -> EventType:
|
||||
return EventType(
|
||||
type=type_,
|
||||
description="A thing happened.",
|
||||
payload_model=_SamplePayload,
|
||||
)
|
||||
|
||||
|
||||
def test_register_then_get_returns_the_event_type(isolated_event_catalog: None) -> None:
|
||||
from app.event_bus.catalog import catalog
|
||||
catalog.register(_event_type())
|
||||
|
||||
assert catalog.get("test.thing") is not None
|
||||
assert catalog.get("test.thing").type == "test.thing"
|
||||
|
||||
|
||||
def test_get_unknown_type_returns_none(isolated_event_catalog: None) -> None:
|
||||
from app.event_bus.catalog import catalog
|
||||
assert catalog.get("does.not.exist") is None
|
||||
|
||||
|
||||
def test_register_duplicate_type_raises(isolated_event_catalog: None) -> None:
|
||||
"""A type is a contract; registering it twice is a bug, not an override."""
|
||||
from app.event_bus.catalog import catalog
|
||||
catalog.register(_event_type())
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
catalog.register(_event_type())
|
||||
|
||||
|
||||
def test_all_is_a_defensive_snapshot(isolated_event_catalog: None) -> None:
|
||||
"""Mutating the returned dict must not corrupt the registry."""
|
||||
from app.event_bus.catalog import catalog
|
||||
catalog.register(_event_type())
|
||||
|
||||
snapshot = catalog.all()
|
||||
snapshot.clear()
|
||||
|
||||
assert catalog.get("test.thing") is not None
|
||||
|
||||
|
||||
def test_payload_schema_is_derived_from_the_payload_model() -> None:
|
||||
"""The JSON Schema a UI/validator consumes comes from the payload model."""
|
||||
event_type = _event_type()
|
||||
|
||||
assert event_type.payload_schema == _SamplePayload.model_json_schema()
|
||||
|
||||
|
||||
def test_each_catalog_instance_has_its_own_registry() -> None:
|
||||
"""Two EventCatalog instances are fully independent."""
|
||||
a = EventCatalog()
|
||||
b = EventCatalog()
|
||||
|
||||
a.register(_event_type())
|
||||
|
||||
assert a.get("test.thing") is not None
|
||||
assert b.get("test.thing") is None
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"""``document.entered_folder`` payload contract + catalog registration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.event_bus.catalog import catalog
|
||||
from app.event_bus.events.document_entered_folder import (
|
||||
EVENT_TYPE,
|
||||
DocumentEnteredFolderPayload,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _payload(**overrides: object) -> DocumentEnteredFolderPayload:
|
||||
base: dict[str, object] = {
|
||||
"document_id": 42,
|
||||
"folder_id": 7,
|
||||
"document_type": "FILE",
|
||||
"title": "Q3 report.pdf",
|
||||
}
|
||||
base.update(overrides)
|
||||
return DocumentEnteredFolderPayload(**base)
|
||||
|
||||
|
||||
def test_payload_carries_the_filterable_fields() -> None:
|
||||
payload = _payload(connector_id=12, created_by_id="abc")
|
||||
|
||||
assert payload.document_id == 42
|
||||
assert payload.folder_id == 7
|
||||
assert payload.document_type == "FILE"
|
||||
assert payload.connector_id == 12
|
||||
|
||||
|
||||
def test_first_placement_is_not_a_move() -> None:
|
||||
"""No previous folder (created or AI-sorted into place) → not a move."""
|
||||
assert _payload(previous_folder_id=None).is_move is False
|
||||
|
||||
|
||||
def test_change_between_folders_is_a_move() -> None:
|
||||
assert _payload(previous_folder_id=3).is_move is True
|
||||
|
||||
|
||||
def test_is_move_is_serialized_for_filtering() -> None:
|
||||
"""Filters match against the dumped payload, so ``is_move`` must appear there."""
|
||||
dumped = _payload(previous_folder_id=3).model_dump()
|
||||
|
||||
assert dumped["is_move"] is True
|
||||
|
||||
|
||||
def test_event_type_is_registered_in_the_catalog() -> None:
|
||||
registered = catalog.get(EVENT_TYPE)
|
||||
|
||||
assert registered is not None
|
||||
assert registered.payload_model is DocumentEnteredFolderPayload
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"""payload_if_entered_folder: decides whether a document commit warrants an event."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.event_bus.events.document_entered_folder import payload_if_entered_folder
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _call(**overrides: Any) -> dict[str, Any] | None:
|
||||
defaults: dict[str, Any] = {
|
||||
"document_id": 1,
|
||||
"search_space_id": 10,
|
||||
"new_folder_id": 7,
|
||||
"previous_folder_id": None,
|
||||
"folder_id_changed": True,
|
||||
"status_state": "ready",
|
||||
"document_type": "FILE",
|
||||
"title": "report.pdf",
|
||||
"connector_id": None,
|
||||
"created_by_id": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return payload_if_entered_folder(**defaults)
|
||||
|
||||
|
||||
def test_folder_set_ready_fires() -> None:
|
||||
result = _call()
|
||||
|
||||
assert result is not None
|
||||
assert result["event_type"] == "document.entered_folder"
|
||||
assert result["search_space_id"] == 10
|
||||
assert result["payload"]["folder_id"] == 7
|
||||
assert result["payload"]["previous_folder_id"] is None
|
||||
|
||||
|
||||
def test_no_folder_is_silent() -> None:
|
||||
assert _call(new_folder_id=None) is None
|
||||
|
||||
|
||||
def test_not_ready_is_silent() -> None:
|
||||
assert _call(status_state="processing") is None
|
||||
|
||||
|
||||
def test_folder_unchanged_is_silent() -> None:
|
||||
assert _call(folder_id_changed=False) is None
|
||||
|
||||
|
||||
def test_move_carries_previous_folder_id() -> None:
|
||||
result = _call(previous_folder_id=3, new_folder_id=7)
|
||||
|
||||
assert result is not None
|
||||
assert result["payload"]["previous_folder_id"] == 3
|
||||
assert result["payload"]["folder_id"] == 7
|
||||
53
surfsense_backend/tests/unit/event_bus/test_event.py
Normal file
53
surfsense_backend/tests/unit/event_bus/test_event.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""``Event`` contract: carry caller facts + engine-stamped id/time, round-trip JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.event_bus.event import Event
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_event_carries_caller_supplied_facts() -> None:
|
||||
"""The three caller inputs are stored verbatim."""
|
||||
event = Event(
|
||||
event_type="document.indexed",
|
||||
payload={"document_id": 42, "content_type": "pdf"},
|
||||
search_space_id=7,
|
||||
)
|
||||
|
||||
assert event.event_type == "document.indexed"
|
||||
assert event.payload == {"document_id": 42, "content_type": "pdf"}
|
||||
assert event.search_space_id == 7
|
||||
|
||||
|
||||
def test_event_stamps_identity_and_time_when_not_supplied() -> None:
|
||||
"""Engine stamps id + time so subscribers can dedup/order."""
|
||||
event = Event(event_type="x.happened", payload={}, search_space_id=1)
|
||||
|
||||
assert event.event_id
|
||||
assert isinstance(event.occurred_at, datetime)
|
||||
|
||||
|
||||
def test_event_ids_are_unique_per_instance() -> None:
|
||||
"""Two events published with identical content are still distinct facts."""
|
||||
first = Event(event_type="x.happened", payload={}, search_space_id=1)
|
||||
second = Event(event_type="x.happened", payload={}, search_space_id=1)
|
||||
|
||||
assert first.event_id != second.event_id
|
||||
|
||||
|
||||
def test_event_survives_json_round_trip() -> None:
|
||||
"""Serialize → deserialize reproduces the event (subscribers queue it as JSON)."""
|
||||
original = Event(
|
||||
event_type="podcast.generated",
|
||||
payload={"podcast_id": 9, "duration_s": 123.5},
|
||||
search_space_id=3,
|
||||
)
|
||||
|
||||
restored = Event.model_validate_json(original.model_dump_json())
|
||||
|
||||
assert restored == original
|
||||
Loading…
Add table
Add a link
Reference in a new issue