"""Tests for SQLAlchemy ORM models (omnigent/db/db_models.py). Verifies that each ORM model can be instantiated, persisted, read back, and that relationships, defaults, nullable columns, and constraints behave as expected. """ from __future__ import annotations import hashlib import time import pytest from sqlalchemy.exc import IntegrityError, OperationalError from omnigent.db.db_models import ( SqlAccountToken, SqlAgent, SqlComment, SqlConversation, SqlConversationItem, SqlConversationLabel, SqlFile, SqlHost, SqlPolicy, SqlSessionPermission, SqlUser, SqlUserDailyCost, ) from omnigent.db.enum_codecs import ( encode_account_token_kind, encode_agent_kind, encode_comment_status, encode_conversation_kind, encode_host_status, encode_item_status, encode_item_type, encode_policy_scope, encode_policy_type, ) from omnigent.db.utils import get_or_create_engine, make_managed_session_maker # ── helpers ─────────────────────────────────────────── def _now() -> int: return int(time.time()) def _make_agent( id: str = "ag_test1", name: str = "test-agent", kind: str = "template", ) -> SqlAgent: return SqlAgent( id=id, created_at=_now(), name=name, bundle_location="ag_test1/abc123", version=1, kind=encode_agent_kind(kind), ) def _make_conversation( id: str = "conv_test1", agent_id: str | None = None, parent_conversation_id: str | None = None, root_conversation_id: str | None = None, kind: str = "default", title: str | None = None, ) -> SqlConversation: return SqlConversation( id=id, created_at=_now(), updated_at=_now(), kind=encode_conversation_kind(kind), agent_id=agent_id, parent_conversation_id=parent_conversation_id, root_conversation_id=root_conversation_id or id, title=title, ) def _make_item( id: str = "msg_test1", conversation_id: str = "conv_test1", position: int = 0, ) -> SqlConversationItem: return SqlConversationItem( id=id, conversation_id=conversation_id, response_id="resp_test1", created_at=_now(), status=encode_item_status("completed"), position=position, type=encode_item_type("message"), data='{"content": [{"type": "text", "text": "hello"}]}', search_text="hello", ) # ── SqlAgent ────────────────────────────────────────── class TestSqlAgent: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) agent = _make_agent() with managed() as session: session.add(agent) with managed() as session: loaded = session.get(SqlAgent, (0, "ag_test1")) assert loaded is not None assert loaded.name == "test-agent" assert loaded.version == 1 assert loaded.description is None assert loaded.updated_at is None assert loaded.kind == encode_agent_kind("template") def test_nullable_columns(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) agent = _make_agent() agent.description = "A test agent" agent.updated_at = _now() with managed() as session: session.add(agent) with managed() as session: loaded = session.get(SqlAgent, (0, "ag_test1")) assert loaded is not None assert loaded.description == "A test agent" assert loaded.updated_at is not None def test_session_scoped_agent_kind(self, db_uri: str) -> None: """A session-scoped agent is stored with kind='session'.""" engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) agent = _make_agent(kind="session") with managed() as session: session.add(agent) with managed() as session: loaded = session.get(SqlAgent, (0, "ag_test1")) assert loaded is not None assert loaded.kind == encode_agent_kind("session") def test_multiple_session_agents_allowed(self, db_uri: str) -> None: """Multiple session-scoped agents are permitted (no unique constraint on kind).""" engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) a1 = _make_agent(id="ag_1", name="agent-1", kind="session") a2 = _make_agent(id="ag_2", name="agent-2", kind="session") with managed() as session: session.add(a1) session.add(a2) # ── SqlFile ─────────────────────────────────────────── class TestSqlFile: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) f = SqlFile( id="file_test1", created_at=_now(), filename="report.pdf", bytes=12345, content_type="application/pdf", ) with managed() as session: session.add(f) with managed() as session: loaded = session.get(SqlFile, (0, "file_test1")) assert loaded is not None assert loaded.filename == "report.pdf" assert loaded.bytes == 12345 assert loaded.content_type == "application/pdf" def test_nullable_content_type(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) f = SqlFile( id="file_test2", created_at=_now(), filename="data.bin", bytes=100, ) with managed() as session: session.add(f) with managed() as session: loaded = session.get(SqlFile, (0, "file_test2")) assert loaded is not None assert loaded.content_type is None # ── SqlUser ─────────────────────────────────────────── class TestSqlUser: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) user = SqlUser(id="alice@example.com", is_admin=False) with managed() as session: session.add(user) with managed() as session: loaded = session.get(SqlUser, (0, "alice@example.com")) assert loaded is not None assert loaded.is_admin is False assert loaded.password_hash is None assert loaded.created_at is None def test_admin_user(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) user = SqlUser( id="admin@example.com", is_admin=True, password_hash="$argon2id$hash", created_at=_now(), ) with managed() as session: session.add(user) with managed() as session: loaded = session.get(SqlUser, (0, "admin@example.com")) assert loaded is not None assert loaded.is_admin is True assert loaded.password_hash == "$argon2id$hash" def test_duplicate_id_raises(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) with managed() as session: session.add(SqlUser(id="dup@example.com", is_admin=False)) with pytest.raises(IntegrityError): with managed() as session: session.add(SqlUser(id="dup@example.com", is_admin=True)) # ── SqlAccountToken ─────────────────────────────────── class TestSqlAccountToken: def test_persist_invite_token(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) now = _now() token = SqlAccountToken( id="tok_invite_abc", kind=encode_account_token_kind("invite"), created_at=now, expires_at=now + 3600, created_by="admin@example.com", invited_is_admin=True, ) with managed() as session: session.add(token) with managed() as session: loaded = session.get(SqlAccountToken, (0, "tok_invite_abc")) assert loaded is not None assert loaded.kind == encode_account_token_kind("invite") assert loaded.user_id is None assert loaded.redeemed_at is None assert loaded.invited_is_admin is True def test_persist_magic_token(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) now = _now() token = SqlAccountToken( id="tok_magic_xyz", kind=encode_account_token_kind("magic"), user_id="alice@example.com", created_at=now, expires_at=now + 300, ) with managed() as session: session.add(token) with managed() as session: loaded = session.get(SqlAccountToken, (0, "tok_magic_xyz")) assert loaded is not None assert loaded.kind == encode_account_token_kind("magic") assert loaded.user_id == "alice@example.com" def test_check_constraint_rejects_invalid_kind(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) now = _now() # An out-of-range int code must be rejected by ck_account_tokens_kind. token = SqlAccountToken( id="tok_bad", kind=99, created_at=now, expires_at=now + 3600, ) with pytest.raises((IntegrityError, OperationalError)): with managed() as session: session.add(token) # ── SqlConversation ─────────────────────────────────── class TestSqlConversation: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation(title="Hello World") with managed() as session: session.add(conv) with managed() as session: loaded = session.get(SqlConversation, (0, "conv_test1")) assert loaded is not None assert loaded.title == "Hello World" assert loaded.kind == encode_conversation_kind("default") assert loaded.archived is False def test_defaults(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() with managed() as session: session.add(conv) with managed() as session: loaded = session.get(SqlConversation, (0, "conv_test1")) assert loaded is not None assert loaded.runner_id is None assert loaded.host_id is None assert loaded.reasoning_effort is None assert loaded.model_override is None assert loaded.external_session_id is None assert loaded.workspace is None assert loaded.git_branch is None assert loaded.session_state is None assert loaded.session_usage is None def test_check_constraint_rejects_invalid_kind(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() # An out-of-range int code must be rejected by ck_conversations_kind. conv.kind = 99 with pytest.raises((IntegrityError, OperationalError)): with managed() as session: session.add(conv) def test_sub_agent_kind(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) parent = _make_conversation(id="conv_parent") child = _make_conversation( id="conv_child", kind="sub_agent", parent_conversation_id="conv_parent", root_conversation_id="conv_parent", title="summarizer", ) with managed() as session: session.add(parent) session.add(child) with managed() as session: loaded = session.get(SqlConversation, (0, "conv_child")) assert loaded is not None assert loaded.kind == encode_conversation_kind("sub_agent") assert loaded.parent_conversation_id == "conv_parent" assert loaded.root_conversation_id == "conv_parent" def test_delete_parent_leaves_children_without_fk(self, db_uri: str) -> None: """Without DB-level FK cascade, deleting a parent leaves child rows intact. The application (delete_conversation) is responsible for cleaning up the subtree explicitly. """ engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) parent = _make_conversation(id="conv_parent2") child = _make_conversation( id="conv_child2", kind="sub_agent", parent_conversation_id="conv_parent2", root_conversation_id="conv_parent2", title="child", ) with managed() as session: session.add(parent) session.add(child) with managed() as session: p = session.get(SqlConversation, (0, "conv_parent2")) assert p is not None session.delete(p) # Without FK cascade the child is NOT automatically deleted. with managed() as session: assert session.get(SqlConversation, (0, "conv_child2")) is not None # ── SqlConversationItem ─────────────────────────────── class TestSqlConversationItem: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() item = _make_item() with managed() as session: session.add(conv) session.add(item) with managed() as session: loaded = session.get(SqlConversationItem, (0, "conv_test1", "msg_test1")) assert loaded is not None assert loaded.conversation_id == "conv_test1" assert loaded.type == encode_item_type("message") assert loaded.position == 0 assert loaded.status == encode_item_status("completed") assert loaded.created_by is None def test_unique_position_per_conversation(self, db_uri: str) -> None: """Two items in the same conversation cannot share the same position.""" engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() item1 = _make_item(id="msg_1", position=0) item2 = _make_item(id="msg_2", position=0) with pytest.raises(IntegrityError): with managed() as session: session.add(conv) session.add(item1) session.add(item2) def test_delete_conversation_via_orm_leaves_items_without_fk(self, db_uri: str) -> None: """Without DB-level FK cascade, deleting a conversation leaves its items intact. The application (delete_conversation) is responsible for deleting items explicitly before or after deleting the conversation row. """ engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation(id="conv_del") item = _make_item(id="msg_del", conversation_id="conv_del") with managed() as session: session.add(conv) session.add(item) with managed() as session: c = session.get(SqlConversation, (0, "conv_del")) assert c is not None session.delete(c) # Without FK cascade the item is NOT automatically deleted. with managed() as session: assert session.get(SqlConversationItem, (0, "conv_del", "msg_del")) is not None def test_multiple_items_ordered_by_position(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() with managed() as session: session.add(conv) for i in range(5): session.add(_make_item(id=f"msg_{i}", position=i)) with managed() as session: items = ( session.query(SqlConversationItem) .filter_by(conversation_id="conv_test1") .order_by(SqlConversationItem.position) .all() ) assert len(items) == 5 assert [it.position for it in items] == [0, 1, 2, 3, 4] # ── SqlConversationLabel ────────────────────────────── class TestSqlConversationLabel: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() label = SqlConversationLabel( conversation_id="conv_test1", key="sensitivity", value="confidential", updated_at=_now(), ) with managed() as session: session.add(conv) session.add(label) with managed() as session: loaded = ( session.query(SqlConversationLabel) .filter_by(conversation_id="conv_test1", key="sensitivity") .one() ) assert loaded.value == "confidential" def test_composite_pk_allows_different_keys(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() with managed() as session: session.add(conv) session.add( SqlConversationLabel( conversation_id="conv_test1", key="k1", value="v1", updated_at=_now() ) ) session.add( SqlConversationLabel( conversation_id="conv_test1", key="k2", value="v2", updated_at=_now() ) ) with managed() as session: labels = ( session.query(SqlConversationLabel).filter_by(conversation_id="conv_test1").all() ) assert len(labels) == 2 # ── SqlSessionPermission ───────────────────────────── class TestSqlSessionPermission: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) with managed() as session: session.add(SqlUser(id="alice@example.com", is_admin=False)) session.add(_make_conversation()) perm = SqlSessionPermission( user_id="alice@example.com", conversation_id="conv_test1", level=2, ) with managed() as session: session.add(perm) with managed() as session: loaded = session.get(SqlSessionPermission, (0, "alice@example.com", "conv_test1")) assert loaded is not None assert loaded.level == 2 def test_check_constraint_rejects_invalid_level(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) with managed() as session: session.add(SqlUser(id="bob@example.com", is_admin=False)) session.add(_make_conversation()) perm = SqlSessionPermission( user_id="bob@example.com", conversation_id="conv_test1", level=99, ) with pytest.raises((IntegrityError, OperationalError)): with managed() as session: session.add(perm) # ── SqlComment ──────────────────────────────────────── class TestSqlComment: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) now = _now() comment = SqlComment( id="cmt_test1", conversation_id="conv_test1", path="src/App.tsx", start_index=10, end_index=20, body="Looks good!", status=encode_comment_status("draft"), created_at=now, updated_at=now * 1_000_000, anchor_content="selected text", created_by="alice@example.com", ) conv = _make_conversation() with managed() as session: session.add(conv) session.add(comment) with managed() as session: loaded = session.get(SqlComment, (0, "cmt_test1")) assert loaded is not None assert loaded.path == "src/App.tsx" assert loaded.body == "Looks good!" assert loaded.status == encode_comment_status("draft") assert loaded.anchor_content == "selected text" assert loaded.created_by == "alice@example.com" def test_nullable_anchor_and_created_by(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) now = _now() comment = SqlComment( id="cmt_test2", conversation_id="conv_test1", path="README.md", start_index=0, end_index=5, body="Legacy comment", status=encode_comment_status("addressed"), created_at=now, updated_at=now * 1_000_000, ) conv = _make_conversation() with managed() as session: session.add(conv) session.add(comment) with managed() as session: loaded = session.get(SqlComment, (0, "cmt_test2")) assert loaded is not None assert loaded.anchor_content is None assert loaded.created_by is None # ── SqlPolicy ───────────────────────────────────────── class TestSqlPolicy: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) policy = SqlPolicy( id="pol_test1", name="cost-guard", scope=encode_policy_scope("default"), created_at=_now(), type=encode_policy_type("python"), handler="omnigent.policies.cost_guard:handler", enabled=True, ) with managed() as session: session.add(policy) with managed() as session: loaded = session.get(SqlPolicy, (0, "pol_test1")) assert loaded is not None assert loaded.name == "cost-guard" # The column default stamps sha256(name) on INSERT. assert loaded.name_cksum == hashlib.sha256(b"cost-guard").digest() assert loaded.type == encode_policy_type("python") assert loaded.enabled is True assert loaded.session_id is None def test_unique_constraint_session_name(self, db_uri: str) -> None: """Two policies in the same session cannot share the same name.""" engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) conv = _make_conversation() p1 = SqlPolicy( id="pol_1", name="guard", session_id="conv_test1", scope=encode_policy_scope("session"), created_at=_now(), type=encode_policy_type("python"), handler="mod:fn", ) p2 = SqlPolicy( id="pol_2", name="guard", session_id="conv_test1", scope=encode_policy_scope("session"), created_at=_now(), type=encode_policy_type("python"), handler="mod:fn2", ) with pytest.raises(IntegrityError): with managed() as session: session.add(conv) session.add(p1) session.add(p2) # ── SqlHost ─────────────────────────────────────────── class TestSqlHost: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) now = _now() host = SqlHost( owner="corey@example.com", name="corey-laptop", host_id="host_abc123", status=encode_host_status("online"), created_at=now, updated_at=now, ) with managed() as session: session.add(host) with managed() as session: loaded = session.get(SqlHost, (0, "host_abc123")) assert loaded is not None assert loaded.host_id == "host_abc123" assert loaded.status == encode_host_status("online") assert loaded.token_hash is None assert loaded.sandbox_provider is None def test_check_constraint_rejects_invalid_status(self, db_uri: str) -> None: """ck_hosts_status rejects out-of-range int codes on enforcing backends. SQLite does not enforce CHECK constraints by default, so we verify the constraint exists in the schema without asserting enforcement on SQLite. On Postgres / MySQL the constraint is enforced at runtime. """ import sqlalchemy as sa engine = get_or_create_engine(db_uri) inspector = sa.inspect(engine) checks = {c["name"] for c in inspector.get_check_constraints("hosts")} assert "ck_hosts_status" in checks, "ck_hosts_status must exist on hosts" def test_unique_host_id(self, db_uri: str) -> None: """Duplicate host_id within the same workspace violates the PK.""" engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) now = _now() h1 = SqlHost( owner="a@x.com", name="h1", host_id="host_dup", status=encode_host_status("online"), created_at=now, updated_at=now, ) # Commit h1 first so h2's insert hits a real PK violation at the DB. with managed() as session: session.add(h1) h2 = SqlHost( owner="b@x.com", name="h2", host_id="host_dup", status=encode_host_status("offline"), created_at=now, updated_at=now, ) with pytest.raises(IntegrityError): with managed() as session: session.add(h2) # ── SqlUserDailyCost ────────────────────────────────── class TestSqlUserDailyCost: def test_persist_and_read(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) row = SqlUserDailyCost( user_id="alice@example.com", day_utc="2026-06-16", cost_usd=1.23, ask_approved_usd=0.0, updated_at=_now(), ) with managed() as session: session.add(row) with managed() as session: loaded = session.get(SqlUserDailyCost, (0, "alice@example.com", "2026-06-16")) assert loaded is not None assert loaded.cost_usd == pytest.approx(1.23) assert loaded.ask_approved_usd == pytest.approx(0.0) def test_composite_pk_multiple_days(self, db_uri: str) -> None: engine = get_or_create_engine(db_uri) managed = make_managed_session_maker(engine) with managed() as session: session.add( SqlUserDailyCost( user_id="u1", day_utc="2026-06-15", cost_usd=1.0, ask_approved_usd=0.0, updated_at=_now(), ) ) session.add( SqlUserDailyCost( user_id="u1", day_utc="2026-06-16", cost_usd=2.0, ask_approved_usd=0.0, updated_at=_now(), ) ) with managed() as session: rows = session.query(SqlUserDailyCost).filter_by(user_id="u1").all() assert len(rows) == 2