Files

318 lines
14 KiB
Python

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