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']))