Files
aiturk-hermes-ide/tests/gateway/relay/test_wire_voice_media.py
T

349 lines
14 KiB
Python

"""Unit tests for relay voice-note + per-attachment media-type mapping.
The relay contract gained ``message_type: "voice"`` (connector PR: voice
notes on Discord/Telegram/WhatsApp classify distinctly from audio-file
uploads). The gateway side has two obligations:
1. ``MessageType("voice")`` must parse — it already does, since the enum
predates the wire value — and
2. the connector's rich ``media[]`` array (each entry carries the
per-attachment ``mime``) must land on ``event.media_types`` so run.py's
per-attachment classifiers (``_event_media_is_stt_input``, image vs
document routing) work for relayed events, not just native adapters.
Live-verified failure (staging 2026-08-26): a voice note arrived as
``MessageType.AUDIO`` with ``media_types == []`` — STT never fired and the
agent got a "the user sent an audio file attachment" note instead.
Pure unit tests: no socket, no websockets dependency.
"""
from __future__ import annotations
from gateway.platforms.base import MessageType
from gateway.relay.ws_transport import _event_from_wire
def _wire_event(message_type: str, **extra):
src = {
"platform": "discord",
"chat_id": "chan-1",
"chat_type": "dm",
"user_id": "u-1",
"user_name": "ben",
}
return {
"text": "",
"message_type": message_type,
"source": src,
**extra,
}
class TestVoiceMessageType:
def test_wire_voice_parses_to_message_type_voice(self):
ev = _event_from_wire(_wire_event("voice"))
assert ev.message_type == MessageType.VOICE
def test_wire_audio_still_parses_to_message_type_audio(self):
"""Music files keep the non-STT type — the connector must not have
collapsed them (it doesn't), and the gateway must not either."""
ev = _event_from_wire(_wire_event("audio"))
assert ev.message_type == MessageType.AUDIO
class TestMediaTypesMapping:
def test_media_mimes_land_on_event_media_types(self):
media = [
{
"url": "https://cdn.discordapp.com/attachments/1/2/voice-message.ogg",
"kind": "audio",
"mime": "audio/ogg",
"size": 19197,
"filename": "voice-message.ogg",
},
]
ev = _event_from_wire(
_wire_event("voice", media=media, media_urls=[m["url"] for m in media])
)
assert ev.media_types == ["audio/ogg"]
# media_urls must remain the parallel legacy field (unchanged).
assert ev.media_urls == [media[0]["url"]]
def test_media_types_parallel_to_media_urls_for_multiple_attachments(self):
media = [
{"url": "https://x/photo.png", "kind": "image", "mime": "image/png"},
{"url": "https://x/doc.pdf", "kind": "document", "mime": "application/pdf"},
]
ev = _event_from_wire(
_wire_event("image", media=media, media_urls=[m["url"] for m in media])
)
assert ev.media_types == ["image/png", "application/pdf"]
assert len(ev.media_types) == len(ev.media_urls)
def test_missing_mime_gives_empty_string_at_that_index(self):
"""A media entry without a mime must not shift the alignment between
media_urls[i] and media_types[i] — run.py indexes both by position."""
media = [
{"url": "https://x/photo.png", "kind": "image", "mime": "image/png"},
{"url": "https://x/blob", "kind": "document"}, # no mime
]
ev = _event_from_wire(
_wire_event("image", media=media, media_urls=[m["url"] for m in media])
)
assert ev.media_types == ["image/png", ""]
def test_no_media_field_means_empty_media_types(self):
"""Older connectors (no media[]) — byte-identical to pre-fix."""
ev = _event_from_wire(_wire_event("text"))
assert ev.media_types == []
def test_length_mismatch_resolves_by_url_without_misaligning(self):
"""media_urls and media[] are independent wire fields; consumers index
BOTH by the same i. Resolution is BY URL, so a length disagreement can
no longer misalign anything: each surviving URL keeps its own mime and
an unmatched URL degrades to "" (message-level classification)."""
media = [
{"url": "https://x/a.png", "kind": "image", "mime": "image/png"},
{"url": "https://x/b.pdf", "kind": "document", "mime": "application/pdf"},
]
ev = _event_from_wire(
_wire_event("image", media=media, media_urls=["https://x/a.png"])
)
assert ev.media_urls == ["https://x/a.png"]
assert ev.media_types == ["image/png"]
class TestSttGate:
"""Direct assertions on run.py's STT gate against REAL wire-parsed
events. The PR's acceptance criterion is STT routing, not object
construction — pin the user-visible invariant here.
Note the deployment-ordering consequence: MessageType.VOICE parses on
ANY gateway (the enum predates this PR) and the gate accepts VOICE with
empty media_types, so a NEW connector + OLD gateway already fires STT
for voice notes. Desirable — but it means the gate's behaviour is part
of the wire contract, and it must not silently change."""
def test_new_connector_voice_event_is_stt_eligible(self):
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event(
"voice",
media=[{"url": "https://gw/relay/media/x", "kind": "voice", "mime": "audio/ogg"}],
media_urls=["https://gw/relay/media/x"],
)
)
assert _event_media_is_stt_input(ev, 0) is True
def test_voice_event_stt_eligible_even_without_media_types(self):
"""A new-connector/old-gateway-shaped event — voice type, no
media[] — still fires STT (the gate's VOICE branch doesn't consult
media_types). Pins the rollout behaviour, not just the new one."""
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event("voice", media_urls=["https://gw/relay/media/x"])
)
# No media[] ⇒ no per-attachment mime, but the slot still exists so the
# parallel-array invariant holds (see TestParallelArrayLengthInvariant).
assert ev.media_types == [""]
assert _event_media_is_stt_input(ev, 0) is True
def test_legacy_audio_typed_voice_note_stays_out_of_stt(self):
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event(
"audio",
media=[{"url": "https://gw/relay/media/x", "kind": "voice", "mime": "audio/ogg"}],
media_urls=["https://gw/relay/media/x"],
)
)
assert _event_media_is_stt_input(ev, 0) is False
def test_music_upload_is_never_stt_eligible(self):
from gateway.run import _event_media_is_stt_input
ev = _event_from_wire(
_wire_event(
"audio",
media=[{"url": "https://cdn/x/song.mp3", "kind": "audio", "mime": "audio/mpeg"}],
media_urls=["https://cdn/x/song.mp3"],
)
)
assert _event_media_is_stt_input(ev, 0) is False
class TestUrlMimePairingThroughLocalization:
"""The end-to-end invariant my unit tests originally missed.
media_urls and media_types are indexed BY POSITION by every downstream
classifier. _localize_inbound_media drops entries (a dead connector
re-host) as a NORMAL best-effort path — so if it filters URLs without
filtering MIMEs in lockstep, every surviving attachment inherits a
neighbour's type. Drive the REAL path: wire parse -> localization ->
run.py classifier."""
def _adapter(self):
from gateway.relay.adapter import RelayAdapter
return RelayAdapter.__new__(RelayAdapter)
def test_dropped_first_attachment_does_not_shift_the_second_mime(self):
import asyncio
from gateway.run import _event_media_is_image, _event_media_type_at
rehost = "https://conn.example/relay/media/dead"
kept = "https://cdn.discordapp.com/attachments/1/2/kept.png"
ev = _event_from_wire(
_wire_event(
"document",
media=[
{"url": rehost, "kind": "document", "mime": "application/pdf"},
{"url": kept, "kind": "image", "mime": "image/png"},
],
media_urls=[rehost, kept],
)
)
assert ev.media_types == ["application/pdf", "image/png"]
adapter = self._adapter()
adapter._media_client = None
adapter._get_media_client = lambda: None # type: ignore[method-assign]
asyncio.run(adapter._localize_inbound_media(ev))
# The dead re-host is dropped; the PNG must keep ITS OWN mime.
assert ev.media_urls == [kept]
assert ev.media_types == ["image/png"]
assert _event_media_type_at(ev, 0) == "image/png"
assert _event_media_is_image(ev, 0) is True
def test_reordered_media_vs_media_urls_resolves_by_url_not_position(self):
"""Equal-length but differently-ordered wire fields must not pair up
positionally — resolve each URL's mime by lookup."""
png = "https://x/a.png"
pdf = "https://x/b.pdf"
ev = _event_from_wire(
_wire_event(
"image",
media=[
{"url": pdf, "mime": "application/pdf"},
{"url": png, "mime": "image/png"},
],
media_urls=[png, pdf],
)
)
assert ev.media_types == ["image/png", "application/pdf"]
def test_url_with_no_matching_media_entry_gets_empty_mime(self):
known = "https://x/a.png"
orphan = "https://x/unknown.bin"
ev = _event_from_wire(
_wire_event(
"image",
media=[{"url": known, "mime": "image/png"}],
media_urls=[known, orphan],
)
)
assert ev.media_types == ["image/png", ""]
def test_media_without_media_urls_yields_no_types(self):
"""No URL list to align to ⇒ the indices are meaningless."""
ev = _event_from_wire(
_wire_event("image", media=[{"url": "https://x/a.png", "mime": "image/png"}])
)
assert ev.media_urls == []
assert ev.media_types == []
class TestParallelArrayLengthInvariant:
"""media_types must ALWAYS have one slot per media_url.
Not merely an indexing nicety: ``merge_pending_message_event``
(gateway/platforms/base.py) EXTENDS both lists when a second media message
is merged into a pending one. If a media_types-less event merges with a
typed one, extend() concatenates lists of different lengths and shifts
every later mime onto the wrong url. Found by self-review after two
rounds of external review flagged this same bug class in adjacent seams."""
def test_media_urls_without_media_still_get_one_empty_slot_each(self):
"""An older connector sends media_urls with no media[]. Padding keeps
the invariant instead of emitting a length-0 media_types."""
ev = _event_from_wire(
_wire_event("image", media_urls=["https://x/a.png", "https://x/b.png"])
)
assert ev.media_types == ["", ""]
assert len(ev.media_types) == len(ev.media_urls)
def test_merging_an_untyped_event_with_a_typed_one_keeps_mimes_on_their_urls(self):
from gateway.platforms.base import merge_pending_message_event
from gateway.run import _event_media_type_at
untyped = _event_from_wire(
_wire_event("image", media_urls=["https://x/old1.png", "https://x/old2.png"])
)
typed = _event_from_wire(
_wire_event(
"document",
media=[{"url": "https://x/new.pdf", "mime": "application/pdf"}],
media_urls=["https://x/new.pdf"],
)
)
pending = {"k": untyped}
merge_pending_message_event(pending, "k", typed)
merged = pending["k"]
assert len(merged.media_types) == len(merged.media_urls)
# The PDF's mime must stay on the PDF, not slide onto old1.png.
assert _event_media_type_at(merged, 0) == ""
assert _event_media_type_at(merged, 1) == ""
assert _event_media_type_at(merged, 2) == "application/pdf"
def test_localization_preserves_the_invariant_when_it_drops_entries(self):
import asyncio
from gateway.relay.adapter import RelayAdapter
rehost = "https://conn.example/relay/media/dead"
kept = "https://x/kept.png"
ev = _event_from_wire(
_wire_event("image", media_urls=[rehost, kept]) # no media[] at all
)
assert ev.media_types == ["", ""]
adapter = RelayAdapter.__new__(RelayAdapter)
adapter._media_client = None
adapter._get_media_client = lambda: None # type: ignore[method-assign]
asyncio.run(adapter._localize_inbound_media(ev))
assert ev.media_urls == [kept]
assert len(ev.media_types) == len(ev.media_urls)
def test_localization_normalizes_a_short_media_types_from_any_source(self):
"""Defence in depth: an event that reaches the localizer with a SHORT
media_types (not produced by _event_from_wire — e.g. a synthetic or
replayed event) must come out with one slot per surviving url, not
with the short list passed through."""
import asyncio
from gateway.platforms.base import MessageEvent, MessageType
from gateway.relay.adapter import RelayAdapter
ev = MessageEvent(text="", message_type=MessageType.PHOTO)
ev.media_urls = ["https://x/a.png", "https://x/b.png"]
ev.media_types = [] # empty, while urls exist — the shape that must
# NOT be passed through: consumers would index/merge against a
# zero-length mime list and shift every later entry.
adapter = RelayAdapter.__new__(RelayAdapter)
adapter._media_client = None
adapter._get_media_client = lambda: None # type: ignore[method-assign]
asyncio.run(adapter._localize_inbound_media(ev))
assert ev.media_urls == ["https://x/a.png", "https://x/b.png"]
assert ev.media_types == ["", ""]
assert len(ev.media_types) == len(ev.media_urls)