Merge commit '7972901f15' into dev_mod

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-29 20:28:12 -07:00
commit 80daf46fbf
74 changed files with 1681 additions and 234 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View 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

View 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

View file

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

View file

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

View 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