"""Inbound attachment tests: upload route, anchoring, chat.send integration.""" from __future__ import annotations import json from pathlib import Path import pytest from pheby import protocol as proto from pheby.attachments import AttachmentStore from pheby.server import PhebyServer from test_pheby import FakeClientConnection, make_server @pytest.fixture(autouse=True) def _clear_bridge_run_state(): """Reset hermes_bridge global run state between tests.""" from pheby import hermes_bridge as hb yield hb._ACTIVE_RUNS.clear() hb._TOOL_EVENTS.clear() # ═══════════════════════════════════════════════════════════════════════════ # Store: register_bytes / anchor / describe # ═══════════════════════════════════════════════════════════════════════════ class TestRegisterBytes: @pytest.mark.asyncio async def test_registers_and_describes_inbound(self, tmp_path): store = AttachmentStore(root=tmp_path / "att") desc = await store.register_bytes( b"# hello\nworld\n", conversation_id="c1", filename="notes.md", mime_type="text/markdown", ) assert desc is not None assert desc["direction"] == "inbound" assert desc["filename"] == "notes.md" assert desc["kind"] == "document" assert desc["mime_type"] == "text/markdown" assert desc["size"] == len(b"# hello\nworld\n") assert desc["message_id"] is None # Blob is actually on disk with exact bytes. blob = store.get_blob_path(desc["attachment_id"]) assert blob is not None and blob.read_bytes() == b"# hello\nworld\n" @pytest.mark.asyncio async def test_image_ext_becomes_image_kind(self, tmp_path): store = AttachmentStore(root=tmp_path / "att") desc = await store.register_bytes( b"\x89PNG\r\n", conversation_id="c1", filename="pic.png") assert desc["kind"] == "image" assert desc["inline_image"] is True @pytest.mark.asyncio async def test_bad_mime_falls_back(self, tmp_path): store = AttachmentStore(root=tmp_path / "att") desc = await store.register_bytes( b"x", conversation_id="c1", filename="f.bin", mime_type="not-a-mime") assert desc["mime_type"] == "application/octet-stream" @pytest.mark.asyncio async def test_generic_android_mime_uses_markdown_extension(self, tmp_path): store = AttachmentStore(root=tmp_path / "att") desc = await store.register_bytes( b"# context\n", conversation_id="c1", filename="notes.md", mime_type="application/octet-stream") assert desc["mime_type"] == "text/markdown" @pytest.mark.asyncio async def test_anchor_persists(self, tmp_path): root = tmp_path / "att" store = AttachmentStore(root=root) desc = await store.register_bytes( b"x", conversation_id="c1", filename="f.bin") store.anchor_message(desc["attachment_id"], "msg123") store2 = AttachmentStore(root=root) store2._loaded = False store2._load_index() d2 = store2.describe(desc["attachment_id"]) assert d2["message_id"] == "msg123" # ═══════════════════════════════════════════════════════════════════════════ # HTTP upload route # ═══════════════════════════════════════════════════════════════════════════ class TestUploadRoute: @pytest.mark.asyncio async def test_upload_requires_auth(self, tmp_path): aiohttp = pytest.importorskip("aiohttp") server = make_server(tmp_path) server.config.port = 0 assert await server.start() try: port = server._site._server.sockets[0].getsockname()[1] async with aiohttp.ClientSession() as http: async with http.post( f"http://127.0.0.1:{port}/attachments", params={"conversation_id": "a" * 32}, data=b"hello") as resp: assert resp.status == 401 finally: await server.stop() @pytest.mark.asyncio async def test_upload_roundtrip_without_premature_broadcast(self, tmp_path): aiohttp = pytest.importorskip("aiohttp") server = make_server(tmp_path) server.config.port = 0 client = FakeClientConnection() client.authenticated = True server._clients["t"] = client cid = await server.router.new_conversation("Uploads") assert await server.start() try: port = server._site._server.sockets[0].getsockname()[1] headers = {"Authorization": "Bearer test-secret-abc123"} async with aiohttp.ClientSession() as http: async with http.post( f"http://127.0.0.1:{port}/attachments", params={"conversation_id": cid, "filename": "notes.md"}, data=b"# title\nbody", headers={**headers, "Content-Type": "text/markdown"}) as resp: assert resp.status == 201 body = await resp.json() desc = body["attachment"] assert desc["filename"] == "notes.md" assert desc["direction"] == "inbound" assert desc["conversation_id"] == cid # Unsent uploads stay private and out of conversation history. assert not [e for e in client.ws.events() if e["type"] == proto.S_ATTACHMENT_ADDED] assert server.store.list_for_conversation(cid) == [] # download round-trips the bytes async with aiohttp.ClientSession() as http: async with http.get( f"http://127.0.0.1:{port}{desc['download_path']}", headers=headers) as resp: assert resp.status == 200 assert await resp.read() == b"# title\nbody" finally: await server.stop() @pytest.mark.asyncio async def test_upload_empty_body_rejected(self, tmp_path): aiohttp = pytest.importorskip("aiohttp") server = make_server(tmp_path) server.config.port = 0 cid = await server.router.new_conversation("U") assert await server.start() try: port = server._site._server.sockets[0].getsockname()[1] headers = {"Authorization": "Bearer test-secret-abc123"} async with aiohttp.ClientSession() as http: async with http.post( f"http://127.0.0.1:{port}/attachments", params={"conversation_id": cid}, data=b"", headers=headers) as resp: assert resp.status == 400 finally: await server.stop() @pytest.mark.asyncio async def test_upload_bad_conversation_format(self, tmp_path): aiohttp = pytest.importorskip("aiohttp") server = make_server(tmp_path) server.config.port = 0 assert await server.start() try: port = server._site._server.sockets[0].getsockname()[1] headers = {"Authorization": "Bearer test-secret-abc123"} async with aiohttp.ClientSession() as http: async with http.post( f"http://127.0.0.1:{port}/attachments", params={"conversation_id": "bad/id!"}, data=b"x", headers=headers) as resp: assert resp.status == 400 finally: await server.stop() # ═══════════════════════════════════════════════════════════════════════════ # chat.send with attachment_ids # ═══════════════════════════════════════════════════════════════════════════ class TestChatSendAttachments: def test_user_history_hides_agent_file_context(self): from pheby.hermes_bridge import _display_user_text content = ("[The user sent a text document: 'plan.md'. Its content has been included below.]\n\n" "[Pheby user message]\nreview this\n[/Pheby user message]\n\n" "Attached file: plan.md\n```md\n# private context\n```") assert _display_user_text(content) == "review this" assert _display_user_text("ordinary message") == "ordinary message" @pytest.mark.asyncio async def test_chat_send_with_text_file_inlines_and_anchors( self, tmp_path): server = make_server(tmp_path) client = FakeClientConnection() client.authenticated = True server._clients["t"] = client cid = "a" * 32 desc = await server.store.register_bytes( b"# plan\n1. build\n", conversation_id=cid, filename="plan.md", mime_type="text/markdown") from pheby import hermes_bridge as hb await hb.send_chat(server, cid, "review this", client, "r1", attachment_ids=[desc["attachment_id"]]) event = server.adapter.handled[0] # Agent receives the file, while the history renderer can recover # just the user's own text from the delimited context. assert "```md" in event.text assert "# plan" in event.text assert event.text.startswith("[Pheby user message]\nreview this\n[/Pheby user message]") # media handed to gateway assert len(event.media_urls) == 1 assert Path(event.media_urls[0]).read_bytes() == b"# plan\n1. build\n" assert event.media_types == ["text/markdown"] assert event.media_text_inlined == [True] # anchored to the message id stored = server.store.describe(desc["attachment_id"]) assert stored["message_id"] == event.message_id added = [e for e in client.ws.events() if e["type"] == proto.S_ATTACHMENT_ADDED] assert added[-1]["attachment"]["message_id"] == event.message_id assert server.store.list_for_conversation(cid) == [stored] @pytest.mark.asyncio async def test_chat_send_image_sets_photo_type(self, tmp_path): server = make_server(tmp_path) client = FakeClientConnection() client.authenticated = True server._clients["t"] = client cid = "b" * 32 desc = await server.store.register_bytes( b"\x89PNG\r\n", conversation_id=cid, filename="pic.png", mime_type="image/png") from pheby import hermes_bridge as hb await hb.send_chat(server, cid, "what is this?", client, "r1", attachment_ids=[desc["attachment_id"]]) event = server.adapter.handled[0] assert event.message_type.value == "photo" assert event.media_types == ["image/png"] assert event.media_text_inlined == [False] assert "```" not in event.text from pheby.hermes_bridge import _display_user_text assert _display_user_text(event.text) == "what is this?" @pytest.mark.asyncio async def test_chat_send_wrong_conversation_rejected(self, tmp_path): server = make_server(tmp_path) client = FakeClientConnection() client.authenticated = True server._clients["t"] = client desc = await server.store.register_bytes( b"x", conversation_id="c" * 32, filename="f.bin") from pheby import hermes_bridge as hb await hb.send_chat(server, "d" * 32, "hi", client, "r1", attachment_ids=[desc["attachment_id"]]) events = client.ws.events() assert events[0]["type"] == proto.S_ERROR assert events[0]["error"]["code"] == proto.ERR_NOT_FOUND assert server.adapter.handled == [] @pytest.mark.asyncio async def test_chat_send_reused_attachment_rejected(self, tmp_path): server = make_server(tmp_path) client = FakeClientConnection() client.authenticated = True server._clients["t"] = client cid = "e" * 32 desc = await server.store.register_bytes( b"x", conversation_id=cid, filename="f.bin") server.store.anchor_message(desc["attachment_id"], "existing-msg") await server._handle_chat_send(client, { "type": proto.C_CHAT_SEND, "conversation_id": cid, "text": "hi", "attachment_ids": [desc["attachment_id"]], }, "r9") events = client.ws.events() assert events[0]["type"] == proto.S_ERROR assert events[0]["error"]["code"] == proto.ERR_BAD_REQUEST @pytest.mark.asyncio async def test_chat_send_attachment_shape_validation(self, tmp_path): server = make_server(tmp_path) client = FakeClientConnection() client.authenticated = True cid = "e" * 32 await server._handle_chat_send(client, { "type": proto.C_CHAT_SEND, "conversation_id": cid, "text": "hi", "attachment_ids": "not-a-list", }, "r1") ev = client.ws.events() assert ev[0]["error"]["code"] == proto.ERR_BAD_REQUEST @pytest.mark.asyncio async def test_chat_send_no_attachments_unchanged(self, tmp_path): server = make_server(tmp_path) client = FakeClientConnection() client.authenticated = True server._clients["t"] = client from pheby import hermes_bridge as hb await hb.send_chat(server, "f" * 32, "plain", client, "r1") event = server.adapter.handled[0] assert event.text == "plain" assert event.media_urls == [] assert event.message_type.value == "text"