feat: add guarded State Hub inbox read projection pilot
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ed7-828d-7ca0-a8d4-0c3e5a0c4102
This commit is contained in:
parent
9724b273a1
commit
6fb5ce285c
6 changed files with 343 additions and 0 deletions
93
tests/test_inbox_projection.py
Normal file
93
tests/test_inbox_projection.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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']))
|
||||
Loading…
Add table
Add a link
Reference in a new issue