"""Contract tests for :mod:`decnet.ttp.factory` (E.1.4). Scoped to the factory + composite dispatch contract: env var routing, unknown-name failure, dispatch index correctness, the KNOWN_SOURCE_KINDS WARNING/INFO bridge for unhandled events. """ from __future__ import annotations import asyncio import logging import pytest from decnet.ttp.base import KNOWN_SOURCE_KINDS, TaggerEvent, TolerantTagger from decnet.ttp.factory import CompositeTagger, get_tagger from decnet.web.db.models.ttp import TTPTag def _ev(source_kind: str) -> TaggerEvent: return TaggerEvent( source_kind=source_kind, source_id="src1", attacker_uuid=None, identity_uuid="id1", session_id=None, decky_id=None, payload={}, ) def test_default_returns_composite_with_shipped_lifters(monkeypatch): """E.3.9 onward: the default composite is wired with each shipped lifter. Empty-lifters was the contract-phase shape; once a lifter impl lands the composite carries it. """ monkeypatch.delenv("DECNET_TTP_TAGGER_TYPE", raising=False) t = get_tagger() assert isinstance(t, CompositeTagger) assert t.name == "composite" assert len(t._lifters) >= 1 def test_explicit_composite(monkeypatch): monkeypatch.setenv("DECNET_TTP_TAGGER_TYPE", "composite") assert isinstance(get_tagger(), CompositeTagger) def test_unknown_tagger_type_raises(monkeypatch): monkeypatch.setenv("DECNET_TTP_TAGGER_TYPE", "nope") with pytest.raises(ValueError, match="Unknown tagger"): get_tagger() class _Recorder(TolerantTagger): """Lifter that records calls and returns a single shaped TTPTag.""" def __init__(self, name: str, handles: frozenset[str]) -> None: self.name = name self.HANDLES = handles self.calls: list[str] = [] async def _tag_impl(self, event: TaggerEvent) -> list[TTPTag]: self.calls.append(event.source_kind) return [] def test_composite_dispatch_index_is_built_from_handles(): a = _Recorder("a", frozenset({"command", "email"})) b = _Recorder("b", frozenset({"email", "intel"})) c = CompositeTagger(lifters=[a, b]) assert set(c._by_kind["command"]) == {a} assert set(c._by_kind["email"]) == {a, b} assert set(c._by_kind["intel"]) == {b} def test_composite_only_invokes_claiming_lifters(): a = _Recorder("a", frozenset({"command"})) b = _Recorder("b", frozenset({"email"})) c = CompositeTagger(lifters=[a, b]) asyncio.run(c.tag(_ev("command"))) assert a.calls == ["command"] assert b.calls == [] def test_composite_unhandled_known_kind_logs_warning_once(caplog): c = CompositeTagger(lifters=[]) # Pick any element of KNOWN_SOURCE_KINDS deterministically. known = sorted(KNOWN_SOURCE_KINDS)[0] caplog.set_level(logging.INFO, logger="decnet.ttp.factory") out1 = asyncio.run(c.tag(_ev(known))) out2 = asyncio.run(c.tag(_ev(known))) assert out1 == [] and out2 == [] warnings = [ r for r in caplog.records if r.name == "decnet.ttp.factory" and r.levelno == logging.WARNING ] assert len(warnings) == 1, "expected one WARNING per kind per process" def test_composite_unhandled_unknown_kind_logs_info_once(caplog): c = CompositeTagger(lifters=[]) unknown = "definitely_not_a_real_source_kind_zzz" assert unknown not in KNOWN_SOURCE_KINDS caplog.set_level(logging.INFO, logger="decnet.ttp.factory") asyncio.run(c.tag(_ev(unknown))) asyncio.run(c.tag(_ev(unknown))) infos = [ r for r in caplog.records if r.name == "decnet.ttp.factory" and r.levelno == logging.INFO ] warnings = [ r for r in caplog.records if r.name == "decnet.ttp.factory" and r.levelno == logging.WARNING ] assert len(infos) == 1 assert warnings == [] def test_composite_concatenates_results_from_multiple_lifters(): class Fixed(TolerantTagger): def __init__(self, n: int) -> None: self.name = f"fixed{n}" self.HANDLES = frozenset({"command"}) self._n = n async def _tag_impl(self, event): # Return a list of the right length without constructing # real TTPTag rows — concatenation semantics are what's # under test, not row validity. return [object()] * self._n c = CompositeTagger(lifters=[Fixed(2), Fixed(3)]) out = asyncio.run(c.tag(_ev("command"))) assert len(out) == 5