318 lines
14 KiB
Python
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" |