refactor(yuanbao): consolidate media resolution into dedicated pipeline middlewares
This commit is contained in:
+463
-108
@@ -38,6 +38,9 @@ from gateway.platforms.yuanbao import (
|
||||
OwnerCommandMiddleware,
|
||||
BuildSourceMiddleware,
|
||||
GroupAtGuardMiddleware,
|
||||
QuoteContextMiddleware,
|
||||
MediaResolveMiddleware,
|
||||
PatchAnchorsMiddleware,
|
||||
DispatchMiddleware,
|
||||
InboundPipelineBuilder,
|
||||
YuanbaoAdapter,
|
||||
@@ -308,40 +311,7 @@ class TestInboundPipeline:
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. InboundContext Tests
|
||||
# ============================================================
|
||||
|
||||
class TestInboundContext:
|
||||
def test_default_values(self):
|
||||
"""InboundContext has sensible defaults."""
|
||||
adapter = make_adapter()
|
||||
ctx = InboundContext(adapter=adapter)
|
||||
assert ctx.raw_frames == []
|
||||
assert ctx.push is None
|
||||
assert ctx.decoded_via == ""
|
||||
assert ctx.from_account == ""
|
||||
assert ctx.group_code == ""
|
||||
assert ctx.msg_body == []
|
||||
assert ctx.msg_id == ""
|
||||
assert ctx.chat_id == ""
|
||||
assert ctx.chat_type == ""
|
||||
assert ctx.raw_text == ""
|
||||
assert ctx.media_refs == []
|
||||
assert ctx.owner_command is None
|
||||
assert ctx.source is None
|
||||
assert ctx.msg_type is None
|
||||
|
||||
def test_mutable_fields(self):
|
||||
"""InboundContext fields are mutable."""
|
||||
ctx = make_ctx()
|
||||
ctx.from_account = "alice"
|
||||
ctx.chat_type = "dm"
|
||||
assert ctx.from_account == "alice"
|
||||
assert ctx.chat_type == "dm"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. Individual Middleware Tests
|
||||
# 2. Individual Middleware Tests
|
||||
# ============================================================
|
||||
|
||||
class TestDecodeMiddleware:
|
||||
@@ -720,31 +690,25 @@ class TestCreateInboundPipeline:
|
||||
expected = [
|
||||
"decode",
|
||||
"extract-fields",
|
||||
"recall_guard",
|
||||
"dedup",
|
||||
"skip-self",
|
||||
"chat-routing",
|
||||
"access-guard",
|
||||
"auto-sethome",
|
||||
"extract-content",
|
||||
"placeholder-filter",
|
||||
"owner-command",
|
||||
"build-source",
|
||||
"group-at-guard",
|
||||
"group-attribution",
|
||||
"classify-msg-type",
|
||||
"quote-context",
|
||||
"media-resolve",
|
||||
"patch-anchors",
|
||||
"dispatch",
|
||||
]
|
||||
"""Pipeline can be customized after creation."""
|
||||
pipeline = InboundPipelineBuilder.build()
|
||||
|
||||
async def custom_mw(ctx, next_fn):
|
||||
await next_fn()
|
||||
|
||||
pipeline.use_before("dispatch", "custom", custom_mw)
|
||||
assert "custom" in pipeline.middleware_names
|
||||
idx_custom = pipeline.middleware_names.index("custom")
|
||||
idx_dispatch = pipeline.middleware_names.index("dispatch")
|
||||
assert idx_custom < idx_dispatch
|
||||
assert pipeline.middleware_names == expected
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -861,19 +825,7 @@ if __name__ == "__main__":
|
||||
# ============================================================
|
||||
|
||||
class TestInboundMiddlewareABC:
|
||||
"""Test the InboundMiddleware abstract base class."""
|
||||
|
||||
def test_cannot_instantiate_abc(self):
|
||||
"""InboundMiddleware cannot be instantiated directly."""
|
||||
with pytest.raises(TypeError):
|
||||
InboundMiddleware()
|
||||
|
||||
def test_subclass_must_implement_handle(self):
|
||||
"""Subclass without handle() raises TypeError."""
|
||||
with pytest.raises(TypeError):
|
||||
class BadMiddleware(InboundMiddleware):
|
||||
name = "bad"
|
||||
BadMiddleware()
|
||||
"""Test the InboundMiddleware OOP protocol (callable + named)."""
|
||||
|
||||
def test_subclass_with_handle_works(self):
|
||||
"""Subclass with handle() can be instantiated."""
|
||||
@@ -900,19 +852,14 @@ class TestInboundMiddlewareABC:
|
||||
assert ctx.raw_text == "called"
|
||||
next_fn.assert_awaited_once()
|
||||
|
||||
def test_repr(self):
|
||||
"""Middleware has a useful repr."""
|
||||
class MyMW(InboundMiddleware):
|
||||
name = "my-mw"
|
||||
async def handle(self, ctx, next_fn):
|
||||
pass
|
||||
mw = MyMW()
|
||||
assert "MyMW" in repr(mw)
|
||||
assert "my-mw" in repr(mw)
|
||||
|
||||
|
||||
class TestMiddlewareClasses:
|
||||
"""Test that all concrete middleware classes have correct names and are InboundMiddleware subclasses."""
|
||||
"""Pin the canonical ``name`` of each concrete middleware class.
|
||||
|
||||
These names are referenced by ``InboundPipelineBuilder.build()`` ordering,
|
||||
by ``use_before`` / ``use_after`` insertion in extensions, and by log
|
||||
messages — so they're a real downstream contract worth pinning.
|
||||
"""
|
||||
|
||||
MIDDLEWARE_CLASSES = [
|
||||
(DecodeMiddleware, "decode"),
|
||||
@@ -929,23 +876,12 @@ class TestMiddlewareClasses:
|
||||
(DispatchMiddleware, "dispatch"),
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("cls,expected_name", MIDDLEWARE_CLASSES)
|
||||
def test_is_inbound_middleware(self, cls, expected_name):
|
||||
"""Each middleware class is a subclass of InboundMiddleware."""
|
||||
assert issubclass(cls, InboundMiddleware)
|
||||
|
||||
@pytest.mark.parametrize("cls,expected_name", MIDDLEWARE_CLASSES)
|
||||
def test_has_correct_name(self, cls, expected_name):
|
||||
"""Each middleware class has the expected name."""
|
||||
mw = cls()
|
||||
assert mw.name == expected_name
|
||||
|
||||
@pytest.mark.parametrize("cls,expected_name", MIDDLEWARE_CLASSES)
|
||||
def test_is_callable(self, cls, expected_name):
|
||||
"""Each middleware instance is callable."""
|
||||
mw = cls()
|
||||
assert callable(mw)
|
||||
|
||||
|
||||
class TestPipelineOOPRegistration:
|
||||
"""Test that InboundPipeline works with OOP middleware instances."""
|
||||
@@ -991,38 +927,457 @@ class TestPipelineOOPRegistration:
|
||||
await pipeline.execute(make_ctx())
|
||||
assert order == ["oop", "func"]
|
||||
|
||||
def test_use_before_with_middleware_instance(self):
|
||||
"""use_before works with OOP middleware instances."""
|
||||
class MwA(InboundMiddleware):
|
||||
name = "a"
|
||||
async def handle(self, ctx, next_fn): await next_fn()
|
||||
|
||||
class MwB(InboundMiddleware):
|
||||
name = "b"
|
||||
async def handle(self, ctx, next_fn): await next_fn()
|
||||
# ============================================================
|
||||
# QuoteContextMiddleware Tests
|
||||
# ============================================================
|
||||
#
|
||||
# Quote-media resolution used to depend on a process-local
|
||||
# msg_id→resids cache populated by ExtractContentMiddleware. After #27866
|
||||
# made gateway/run.py write @bot user transcript entries with
|
||||
# message_id (symmetric with the observed-group writer at yuanbao.py:2091),
|
||||
# QuoteContextMiddleware's transcript-lookup path covers every quote case
|
||||
# we used to rely on the cache for, so the cache (and those tests) were
|
||||
# removed. ``_extract_quote_context()`` is now a pure (quote_id, quote_text)
|
||||
# extractor; quote media references are populated separately by
|
||||
# ``_extract_media_refs_from_transcript()`` against the transcript store.
|
||||
|
||||
class MwC(InboundMiddleware):
|
||||
name = "c"
|
||||
async def handle(self, ctx, next_fn): await next_fn()
|
||||
class TestQuoteContextMiddleware:
|
||||
"""Tests for QuoteContextMiddleware._extract_quote_context."""
|
||||
|
||||
pipeline = InboundPipeline().use(MwA()).use(MwC())
|
||||
pipeline.use_before("c", MwB())
|
||||
assert pipeline.middleware_names == ["a", "b", "c"]
|
||||
def test_extract_quote_context_no_cloud_data(self):
|
||||
"""Returns (None, None) when cloud_custom_data is empty."""
|
||||
result = QuoteContextMiddleware()._extract_quote_context("")
|
||||
assert result == (None, None)
|
||||
|
||||
def test_use_after_with_middleware_instance(self):
|
||||
"""use_after works with OOP middleware instances."""
|
||||
class MwA(InboundMiddleware):
|
||||
name = "a"
|
||||
async def handle(self, ctx, next_fn): await next_fn()
|
||||
def test_extract_quote_context_no_quote_key(self):
|
||||
"""Returns (None, None) when JSON has no 'quote' key."""
|
||||
cloud_data = json.dumps({"foo": "bar"})
|
||||
result = QuoteContextMiddleware()._extract_quote_context(cloud_data)
|
||||
assert result == (None, None)
|
||||
|
||||
class MwB(InboundMiddleware):
|
||||
name = "b"
|
||||
async def handle(self, ctx, next_fn): await next_fn()
|
||||
def test_extract_quote_context_with_desc(self):
|
||||
"""Extracts quote_id and quote_text from desc."""
|
||||
cloud_data = json.dumps({
|
||||
"quote": {
|
||||
"id": "quoted-msg-001",
|
||||
"desc": "Hello world",
|
||||
"sender_nickname": "Alice",
|
||||
}
|
||||
})
|
||||
quote_id, quote_text = QuoteContextMiddleware()._extract_quote_context(cloud_data)
|
||||
assert quote_id == "quoted-msg-001"
|
||||
assert quote_text == "Alice: Hello world"
|
||||
|
||||
class MwC(InboundMiddleware):
|
||||
name = "c"
|
||||
async def handle(self, ctx, next_fn): await next_fn()
|
||||
def test_extract_quote_context_empty_desc(self):
|
||||
"""When desc is empty, quote_text is None but quote_id is preserved."""
|
||||
cloud_data = json.dumps({
|
||||
"quote": {
|
||||
"id": "quoted-msg-003",
|
||||
"desc": "",
|
||||
"sender_nickname": "Carol",
|
||||
}
|
||||
})
|
||||
quote_id, quote_text = QuoteContextMiddleware()._extract_quote_context(cloud_data)
|
||||
assert quote_id == "quoted-msg-003"
|
||||
assert quote_text is None
|
||||
|
||||
pipeline = InboundPipeline().use(MwA()).use(MwC())
|
||||
pipeline.use_after("a", MwB())
|
||||
assert pipeline.middleware_names == ["a", "b", "c"]
|
||||
def test_extract_quote_context_no_quote_id(self):
|
||||
"""When quote.id is empty, quote_id is None."""
|
||||
cloud_data = json.dumps({
|
||||
"quote": {
|
||||
"id": "",
|
||||
"desc": "some text",
|
||||
}
|
||||
})
|
||||
quote_id, _quote_text = QuoteContextMiddleware()._extract_quote_context(cloud_data)
|
||||
assert quote_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_sets_ctx_fields(self):
|
||||
"""QuoteContextMiddleware.handle() sets ctx.reply_to_message_id, reply_to_text, quote_media_refs.
|
||||
|
||||
With no transcript store wired up, quote_media_refs falls back to []
|
||||
— media resolution from transcript is covered by separate tests.
|
||||
"""
|
||||
cloud_data = json.dumps({
|
||||
"quote": {
|
||||
"id": "quoted-msg-004",
|
||||
"desc": "Check this image",
|
||||
"sender_nickname": "Dave",
|
||||
}
|
||||
})
|
||||
adapter = make_adapter()
|
||||
adapter._session_store = None # no transcript lookup path
|
||||
ctx = make_ctx(adapter=adapter, cloud_custom_data=cloud_data)
|
||||
next_fn = AsyncMock()
|
||||
|
||||
await QuoteContextMiddleware()(ctx, next_fn)
|
||||
|
||||
assert ctx.reply_to_message_id == "quoted-msg-004"
|
||||
assert ctx.reply_to_text == "Dave: Check this image"
|
||||
assert ctx.quote_media_refs == []
|
||||
next_fn.assert_awaited_once()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MediaResolveMiddleware Tests
|
||||
# ============================================================
|
||||
#
|
||||
# After the dispatch refactor, MediaResolveMiddleware is the single entry
|
||||
# point for all inbound media downloads. It merges up to three sources
|
||||
# into ``ctx.media_urls`` / ``ctx.media_types`` (deduped, in this order):
|
||||
#
|
||||
# 1) media carried by the current message itself (always),
|
||||
# 2) quote_media_refs (when reply_to_message_id is set),
|
||||
# 3) recent group-observed media (only when chat_type == "group" and
|
||||
# no quote is present).
|
||||
#
|
||||
# Direct messages skip the observed backfill entirely.
|
||||
|
||||
class TestResolveYbresRefs:
|
||||
"""Direct tests for ``MediaResolveMiddleware._resolve_ybres_refs``.
|
||||
|
||||
This classmethod is the shared engine for both ``_resolve_quote_media``
|
||||
and ``_collect_observed_media``. Patching the two upstream callers from
|
||||
routing tests doesn't exercise its filtering / error-swallowing
|
||||
behavior, so we pin those contracts directly here.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolves_each_ref_in_order(self):
|
||||
"""Successful resolution returns ``(paths, mimes)`` aligned with input order."""
|
||||
adapter = make_adapter()
|
||||
refs = [
|
||||
("rid-1", "image", "a.jpg"),
|
||||
("rid-2", "file", "doc.pdf"),
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_fetch_resource_url",
|
||||
new=AsyncMock(side_effect=["https://fresh/1", "https://fresh/2"]),
|
||||
) as p_fetch, patch.object(
|
||||
MediaResolveMiddleware, "_download_and_cache",
|
||||
new=AsyncMock(side_effect=[
|
||||
("/cache/a.jpg", "image/jpeg"),
|
||||
("/cache/doc.pdf", "application/pdf"),
|
||||
]),
|
||||
) as p_cache:
|
||||
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
|
||||
adapter, refs, log_prefix="test",
|
||||
)
|
||||
|
||||
assert paths == ["/cache/a.jpg", "/cache/doc.pdf"]
|
||||
assert mimes == ["image/jpeg", "application/pdf"]
|
||||
assert p_fetch.await_count == 2
|
||||
# filename from the ref tuple is forwarded to download_and_cache
|
||||
cache_kwargs = [c.kwargs for c in p_cache.await_args_list]
|
||||
assert cache_kwargs[0]["file_name"] == "a.jpg"
|
||||
assert cache_kwargs[0]["kind"] == "image"
|
||||
assert cache_kwargs[0]["resource_id"] == "rid-1"
|
||||
assert cache_kwargs[1]["file_name"] == "doc.pdf"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_unresolvable_kinds(self):
|
||||
"""Refs whose kind is outside ``_RESOLVABLE_MEDIA_KINDS`` are dropped silently."""
|
||||
adapter = make_adapter()
|
||||
refs = [
|
||||
("rid-v", "video", ""), # not resolvable
|
||||
("rid-i", "image", "ok.jpg"), # resolvable
|
||||
("rid-?", "unknown", ""), # not resolvable
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_fetch_resource_url",
|
||||
new=AsyncMock(return_value="https://fresh/i"),
|
||||
) as p_fetch, patch.object(
|
||||
MediaResolveMiddleware, "_download_and_cache",
|
||||
new=AsyncMock(return_value=("/cache/ok.jpg", "image/jpeg")),
|
||||
) as p_cache:
|
||||
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
|
||||
adapter, refs, log_prefix="test",
|
||||
)
|
||||
|
||||
assert paths == ["/cache/ok.jpg"]
|
||||
assert mimes == ["image/jpeg"]
|
||||
# Only the resolvable ref hit the network.
|
||||
p_fetch.assert_awaited_once()
|
||||
p_cache.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_failure_is_swallowed_per_ref(self):
|
||||
"""If ``_fetch_resource_url`` raises, that ref is skipped — not the whole batch."""
|
||||
adapter = make_adapter()
|
||||
refs = [
|
||||
("rid-bad", "image", ""),
|
||||
("rid-ok", "image", ""),
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_fetch_resource_url",
|
||||
new=AsyncMock(side_effect=[RuntimeError("boom"), "https://fresh/ok"]),
|
||||
), patch.object(
|
||||
MediaResolveMiddleware, "_download_and_cache",
|
||||
new=AsyncMock(return_value=("/cache/ok.jpg", "image/jpeg")),
|
||||
) as p_cache:
|
||||
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
|
||||
adapter, refs, log_prefix="test",
|
||||
)
|
||||
|
||||
# bad ref dropped; good ref preserved
|
||||
assert paths == ["/cache/ok.jpg"]
|
||||
assert mimes == ["image/jpeg"]
|
||||
# download_and_cache was only invoked for the surviving ref
|
||||
p_cache.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_miss_drops_ref(self):
|
||||
"""If ``_download_and_cache`` returns None, the ref is dropped."""
|
||||
adapter = make_adapter()
|
||||
refs = [("rid-1", "image", "")]
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_fetch_resource_url",
|
||||
new=AsyncMock(return_value="https://fresh/1"),
|
||||
), patch.object(
|
||||
MediaResolveMiddleware, "_download_and_cache",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
|
||||
adapter, refs, log_prefix="test",
|
||||
)
|
||||
|
||||
assert paths == []
|
||||
assert mimes == []
|
||||
|
||||
|
||||
class TestMediaResolveMiddlewareRouting:
|
||||
"""Branch-routing tests for MediaResolveMiddleware.handle()."""
|
||||
|
||||
def _make_resolved_ctx(self, *, chat_type: str, reply_to: str = None,
|
||||
quote_media_refs=None, raw_text: str = "hello"):
|
||||
adapter = make_adapter()
|
||||
ctx = make_ctx(
|
||||
adapter=adapter,
|
||||
chat_type=chat_type,
|
||||
reply_to_message_id=reply_to,
|
||||
quote_media_refs=list(quote_media_refs or []),
|
||||
raw_text=raw_text,
|
||||
media_refs=[], # no own attachments by default
|
||||
)
|
||||
return adapter, ctx
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_no_quote_skips_observed_backfill(self):
|
||||
"""In dm chats, observed-media backfill is never invoked."""
|
||||
_adapter, ctx = self._make_resolved_ctx(chat_type="dm")
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_resolve_media_urls",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
) as p_own, patch.object(
|
||||
MediaResolveMiddleware, "_resolve_quote_media",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
) as p_quote, patch.object(
|
||||
MediaResolveMiddleware, "_collect_observed_media",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
) as p_observed:
|
||||
next_fn = AsyncMock()
|
||||
await MediaResolveMiddleware()(ctx, next_fn)
|
||||
|
||||
p_own.assert_awaited_once()
|
||||
p_quote.assert_not_awaited()
|
||||
p_observed.assert_not_awaited()
|
||||
next_fn.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_with_quote_resolves_quote_media_only(self):
|
||||
"""In dm chats with a quote, only quote media (plus own) is resolved."""
|
||||
_adapter, ctx = self._make_resolved_ctx(
|
||||
chat_type="dm",
|
||||
reply_to="quoted-001",
|
||||
quote_media_refs=[("rid-q1", "image", "")],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_resolve_media_urls",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
) as p_own, patch.object(
|
||||
MediaResolveMiddleware, "_resolve_quote_media",
|
||||
new=AsyncMock(return_value=(["/cache/q1.jpg"], ["image/jpeg"])),
|
||||
) as p_quote, patch.object(
|
||||
MediaResolveMiddleware, "_collect_observed_media",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
) as p_observed:
|
||||
next_fn = AsyncMock()
|
||||
await MediaResolveMiddleware()(ctx, next_fn)
|
||||
|
||||
p_own.assert_awaited_once()
|
||||
p_quote.assert_awaited_once()
|
||||
p_observed.assert_not_awaited()
|
||||
assert ctx.media_urls == ["/cache/q1.jpg"]
|
||||
assert ctx.media_types == ["image/jpeg"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_no_quote_runs_observed_backfill(self):
|
||||
"""In group chats without quote, observed-media backfill is invoked."""
|
||||
_adapter, ctx = self._make_resolved_ctx(chat_type="group")
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_resolve_media_urls",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
), patch.object(
|
||||
MediaResolveMiddleware, "_resolve_quote_media",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
) as p_quote, patch.object(
|
||||
MediaResolveMiddleware, "_collect_observed_media",
|
||||
new=AsyncMock(return_value=(["/cache/o1.jpg"], ["image/jpeg"])),
|
||||
) as p_observed:
|
||||
next_fn = AsyncMock()
|
||||
await MediaResolveMiddleware()(ctx, next_fn)
|
||||
|
||||
p_quote.assert_not_awaited()
|
||||
p_observed.assert_awaited_once()
|
||||
assert ctx.media_urls == ["/cache/o1.jpg"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_with_quote_skips_observed_backfill(self):
|
||||
"""In group chats with a quote, only quote media is resolved (no backfill)."""
|
||||
_adapter, ctx = self._make_resolved_ctx(
|
||||
chat_type="group",
|
||||
reply_to="quoted-002",
|
||||
quote_media_refs=[("rid-q2", "image", "")],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_resolve_media_urls",
|
||||
new=AsyncMock(return_value=([], [])),
|
||||
), patch.object(
|
||||
MediaResolveMiddleware, "_resolve_quote_media",
|
||||
new=AsyncMock(return_value=(["/cache/q2.jpg"], ["image/jpeg"])),
|
||||
) as p_quote, patch.object(
|
||||
MediaResolveMiddleware, "_collect_observed_media",
|
||||
new=AsyncMock(return_value=(["/cache/o2.jpg"], ["image/jpeg"])),
|
||||
) as p_observed:
|
||||
next_fn = AsyncMock()
|
||||
await MediaResolveMiddleware()(ctx, next_fn)
|
||||
|
||||
p_quote.assert_awaited_once()
|
||||
p_observed.assert_not_awaited()
|
||||
assert ctx.media_urls == ["/cache/q2.jpg"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merges_and_dedupes_three_sources(self):
|
||||
"""Own + quote sources are merged with dedup applied."""
|
||||
_adapter, ctx = self._make_resolved_ctx(
|
||||
chat_type="dm",
|
||||
reply_to="quoted-003",
|
||||
quote_media_refs=[("rid", "image", "")],
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_resolve_media_urls",
|
||||
new=AsyncMock(return_value=(["/cache/a.jpg"], ["image/jpeg"])),
|
||||
), patch.object(
|
||||
MediaResolveMiddleware, "_resolve_quote_media",
|
||||
# Same path as own → must be deduped.
|
||||
new=AsyncMock(return_value=(["/cache/a.jpg", "/cache/b.jpg"],
|
||||
["image/jpeg", "image/png"])),
|
||||
):
|
||||
next_fn = AsyncMock()
|
||||
await MediaResolveMiddleware()(ctx, next_fn)
|
||||
|
||||
assert ctx.media_urls == ["/cache/a.jpg", "/cache/b.jpg"]
|
||||
assert ctx.media_types == ["image/jpeg", "image/png"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_placeholder_recheck_uses_own_count_only(self):
|
||||
"""Placeholder retry-skip uses ``own_count``, not the merged total.
|
||||
|
||||
A bare placeholder text (e.g. ``[image]``) accompanied only by a
|
||||
quote-resolved image is still skippable — quote media must not
|
||||
flip a placeholder into a non-placeholder.
|
||||
"""
|
||||
_adapter, ctx = self._make_resolved_ctx(
|
||||
chat_type="dm",
|
||||
reply_to="quoted-004",
|
||||
quote_media_refs=[("rid", "image", "")],
|
||||
raw_text="[image]",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
MediaResolveMiddleware, "_resolve_media_urls",
|
||||
new=AsyncMock(return_value=([], [])), # no own media
|
||||
), patch.object(
|
||||
MediaResolveMiddleware, "_resolve_quote_media",
|
||||
new=AsyncMock(return_value=(["/cache/q.jpg"], ["image/jpeg"])),
|
||||
), patch.object(
|
||||
PlaceholderFilterMiddleware, "is_skippable_placeholder",
|
||||
return_value=True,
|
||||
) as p_check:
|
||||
next_fn = AsyncMock()
|
||||
await MediaResolveMiddleware()(ctx, next_fn)
|
||||
|
||||
# Pipeline short-circuited despite quote media being present.
|
||||
next_fn.assert_not_awaited()
|
||||
# And the second-pass check was called with own_count == 0.
|
||||
p_check.assert_called_once()
|
||||
_text_arg, count_arg = p_check.call_args.args
|
||||
assert count_arg == 0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PatchAnchorsMiddleware Tests
|
||||
# ============================================================
|
||||
|
||||
class TestPatchAnchorsMiddleware:
|
||||
"""Tests for PatchAnchorsMiddleware._patch()."""
|
||||
|
||||
def test_no_op_when_text_or_urls_empty(self):
|
||||
assert PatchAnchorsMiddleware._patch("", [], []) == ""
|
||||
assert PatchAnchorsMiddleware._patch("hello", [], []) == "hello"
|
||||
|
||||
def test_replaces_image_anchor_with_local_path(self):
|
||||
text = "look [image|ybres:abc] please"
|
||||
out = PatchAnchorsMiddleware._patch(
|
||||
text, ["/cache/x.jpg"], ["image/jpeg"],
|
||||
)
|
||||
assert out == "look [image: /cache/x.jpg] please"
|
||||
|
||||
def test_replaces_file_anchor_with_filename_label(self):
|
||||
text = "see [file:doc.pdf|ybres:rid-1]"
|
||||
out = PatchAnchorsMiddleware._patch(
|
||||
text, ["/cache/doc.pdf"], ["application/pdf"],
|
||||
)
|
||||
assert "[file: doc.pdf → /cache/doc.pdf]" in out
|
||||
|
||||
def test_skips_non_local_paths(self):
|
||||
"""URLs not starting with '/' are left untouched."""
|
||||
text = "[image|ybres:abc]"
|
||||
out = PatchAnchorsMiddleware._patch(
|
||||
text, ["https://example.com/x.jpg"], ["image/jpeg"],
|
||||
)
|
||||
# Anchor preserved verbatim because the resolved url is remote.
|
||||
assert out == text
|
||||
|
||||
def test_anchor_kind_image_requires_image_mime(self):
|
||||
"""An [image|...] anchor with a non-image mime is left alone."""
|
||||
text = "[image|ybres:rid]"
|
||||
out = PatchAnchorsMiddleware._patch(
|
||||
text, ["/cache/odd.bin"], ["application/octet-stream"],
|
||||
)
|
||||
assert out == text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_writes_back_to_ctx(self):
|
||||
adapter = make_adapter()
|
||||
ctx = make_ctx(
|
||||
adapter=adapter,
|
||||
raw_text="hi [image|ybres:rid]",
|
||||
media_urls=["/cache/y.png"],
|
||||
media_types=["image/png"],
|
||||
)
|
||||
next_fn = AsyncMock()
|
||||
await PatchAnchorsMiddleware()(ctx, next_fn)
|
||||
assert ctx.raw_text == "hi [image: /cache/y.png]"
|
||||
next_fn.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user