hub-core/tests/test_inbox_projection.py
tegwick 6fb5ce285c
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s
feat: add guarded State Hub inbox read projection pilot
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
2026-09-05 10:37:09 +02:00

93 lines
4.6 KiB
Python

import asyncio
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from hub_core.models.agent_message import AgentMessage
from hub_core.runtime.app import create_app
from hub_core.runtime.config import RuntimeSettings
from hub_core.runtime.inbox_projection import SCHEMA, import_snapshot, snapshot_hash
from hub_core.runtime.postgres_store import PostgresPortStore
from hub_core.runtime.store import InMemoryPortStore
def message(**extra):
return dict(id=str(uuid4()), from_agent="sender", to_agent="state-hub", subject="Subject",
body="Preserved body", thread_id=None, read_at=None, archived_at=None,
created_at="2026-09-05T08:00:00Z", **extra)
def bundle(rows):
return dict(schema=SCHEMA, source="state-hub/primary", captured_at="2026-09-05T08:01:00Z",
messages=rows, count=len(rows), content_hash=snapshot_hash(rows))
def test_snapshot_import_and_read_contract(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'inbox.db'}")
factory = async_sessionmaker(engine, expire_on_commit=False)
root = message()
read = {**message(), "read_at": "2026-09-05T08:00:01Z", "thread_id": root["id"]}
archived = {**message(), "archived_at": "2026-09-05T08:00:02Z"}
broadcast = {**message(), "to_agent": "broadcast"}
other = {**message(), "to_agent": "other"}
data = bundle([root, read, archived, broadcast, other])
async def prepare():
async with engine.begin() as conn:
await conn.run_sync(AgentMessage.__table__.create)
async with factory.begin() as session:
result = await import_snapshot(session, data, expected_hash=data['content_hash'], apply=True)
assert result['status'] == 'applied'
async with factory.begin() as session:
assert (await import_snapshot(session, data, expected_hash=data['content_hash'], apply=True))['status'] == 'noop'
async with factory.begin() as session:
changed = bundle([root])
with pytest.raises(ValueError, match='refusing overwrite'):
await import_snapshot(session, changed, expected_hash=changed['content_hash'], apply=True)
asyncio.run(prepare())
settings = RuntimeSettings(backend='postgresql', statehub_inbox_reads=True, api_token='test-reader')
client = TestClient(create_app(settings=settings, port_store=PostgresPortStore(engine)))
url='/ports/projections/statehub-inbox'
headers={'Authorization':'Bearer test-reader'}
assert client.get(url).status_code == 401
assert client.get(url,headers={'Authorization':'Bearer wrong'}).status_code == 401
response=client.get(url,headers=headers)
assert response.status_code == 200
assert {r['id'] for r in response.json()} == {root['id'],read['id'],broadcast['id']}
assert [r['id'] for r in response.json()] == sorted([root['id'],read['id'],broadcast['id']])
assert len(client.get(url,headers=headers,params={'unread_only':True}).json()) == 2
assert client.get(url,headers=headers,params={'from_agent':'missing'}).json() == []
assert len(client.get(url,headers=headers,params={'limit':1}).json()) == 1
assert client.get(url,headers=headers,params={'to_agent':'other'}).status_code == 422
assert client.post(url,headers=headers,json={}).status_code == 405
assert response.headers['x-hub-core-read-mode'] == 'snapshot-pilot'
asyncio.run(engine.dispose())
def test_disabled_and_unconfigured_reader():
c=TestClient(create_app(settings=RuntimeSettings(),port_store=InMemoryPortStore()))
assert c.get('/ports/projections/statehub-inbox').status_code == 404
with pytest.raises(ValueError,match='PostgreSQL and operator token'):
RuntimeSettings(statehub_inbox_reads=True)
@pytest.mark.parametrize('defect',['hash','duplicate','missing_root','cycle'])
def test_invalid_snapshot_fails_before_database_access(defect):
row=message()
data=bundle([row])
if defect=='hash': data['content_hash']='wrong'
if defect=='duplicate': data['messages'].append(row)
if defect=='missing_root':
row['thread_id']=str(uuid4()); data=bundle([row])
if defect=='cycle':
row['thread_id']=row['id']; data=bundle([row])
class EmptySession:
async def execute(self,*args):
class Result:
def scalars(self): return []
return Result()
with pytest.raises(ValueError):
asyncio.run(import_snapshot(EmptySession(),data,expected_hash=data['content_hash']))