Files
sales-trainer/backend/tests/test_db_schema.py

291 lines
8.8 KiB
Python

"""Contract tests for the S4.2 relational schema foundation."""
from __future__ import annotations
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config as AlembicConfig
from sqlalchemy import inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db import Base, create_db_engine
from app.models import AuditEvent, Group, Message, Organization, Persona, TrainingSession, User
EXPECTED_TABLES = {
"organizations",
"users",
"groups",
"personas",
"sessions",
"messages",
"audit_events",
}
def _sqlite_engine(tmp_path: Path):
return create_db_engine(f"sqlite:///{tmp_path / 'schema.db'}")
def test_engine_rejects_dialects_without_partial_index_support():
with pytest.raises(ValueError, match="Unsupported database dialect"):
create_db_engine("mysql://user@localhost/sales_trainer")
def test_metadata_exposes_required_tables_and_tenant_keys(tmp_path: Path):
engine = _sqlite_engine(tmp_path)
Base.metadata.create_all(engine)
inspector = inspect(engine)
assert EXPECTED_TABLES <= set(inspector.get_table_names())
assert {column["name"] for column in inspector.get_columns("organizations")} >= {
"id",
"name",
"plan",
"seats",
"active",
"created_at",
}
assert {column["name"] for column in inspector.get_columns("users")} >= {
"id",
"org_id",
"username",
"password_hash",
"email",
"name",
"role",
"active",
"must_setup",
"accepted_terms",
"accepted_terms_at",
"auth_version",
"created_at",
}
assert {column["name"] for column in inspector.get_columns("groups")} >= {
"id",
"org_id",
"owner_user_id",
"creator_user_id",
"name",
}
assert {column["name"] for column in inspector.get_columns("sessions")} >= {
"id",
"org_id",
"user_id",
"group_id",
"persona_id",
"mode",
"status",
"outcome",
"scenario_json",
"internal_json",
"debrief_json",
}
def test_constraints_preserve_tenant_links_and_attempt_uniqueness(tmp_path: Path):
engine = _sqlite_engine(tmp_path)
Base.metadata.create_all(engine)
with Session(engine) as db:
org = Organization(id="org-a", name="A", plan="trial", seats=5)
other_org = Organization(id="org-b", name="B", plan="trial", seats=5)
user = User(
id="user-a",
org_id="org-a",
username="a",
password_hash="hashed",
email="a@example.com",
name="A User",
role="user",
accepted_terms=True,
auth_version=3,
)
group = Group(
id="group-a",
org_id="org-a",
owner_user_id="user-a",
creator_user_id="user-a",
name="Group A",
)
persona = Persona(id="persona-a", group_id="group-a", tier="base", public_json={})
attempt = TrainingSession(
id="session-a",
org_id="org-a",
user_id="user-a",
group_id="group-a",
persona_id="persona-a",
mode="practice",
status="active",
scenario_json={},
)
db.add_all([org, other_org, user])
db.commit()
db.add(group)
db.commit()
db.add(persona)
db.commit()
db.add(attempt)
db.commit()
valid_audit = AuditEvent(
org_id="org-a",
actor_user_id="user-a",
action="group.created",
subject="group-a",
)
db.add(valid_audit)
db.commit()
cross_tenant_audit = AuditEvent(
org_id="org-b",
actor_user_id="user-a",
action="group.created",
subject="group-b",
)
db.add(cross_tenant_audit)
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
partial_null_audit = AuditEvent(
org_id=None,
actor_user_id="user-a",
action="group.created",
subject="group-a",
)
db.add(partial_null_audit)
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
duplicate_attempt = TrainingSession(
id="session-b",
org_id="org-a",
user_id="user-a",
group_id="group-a",
persona_id="persona-a",
mode="practice",
status="active",
)
db.add(duplicate_attempt)
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
preview_duplicate = TrainingSession(
id="preview-b",
org_id="org-a",
user_id="user-a",
group_id="group-a",
persona_id="persona-a",
mode="preview",
status="completed",
)
db.add(preview_duplicate)
db.commit()
cross_tenant_group = Group(
id="group-b",
org_id="org-b",
owner_user_id="user-a",
name="Invalid cross-tenant group",
)
db.add(cross_tenant_group)
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_metadata_create_all_exposes_relational_defaults(tmp_path: Path):
engine = _sqlite_engine(tmp_path)
Base.metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(text("INSERT INTO organizations (id, name) VALUES ('org-a', 'A')"))
connection.execute(
text(
"INSERT INTO users "
"(id, org_id, username, password_hash, name, role) "
"VALUES ('user-a', 'org-a', 'a', 'hashed', 'A User', 'user')"
)
)
organization = connection.execute(
text("SELECT plan, seats, active FROM organizations WHERE id = 'org-a'")
).one()
user = connection.execute(
text(
"SELECT active, must_setup, accepted_terms, auth_version "
"FROM users WHERE id = 'user-a'"
)
).one()
assert tuple(organization) == ("trial", 5, True)
assert tuple(user) == (True, False, False, 0)
def test_alembic_online_path_rejects_unsupported_dialects(monkeypatch: pytest.MonkeyPatch):
alembic_cfg = AlembicConfig(str(Path(__file__).parents[1] / "alembic.ini"))
alembic_cfg.set_main_option("sqlalchemy.url", "mysql://user@localhost/sales_trainer")
monkeypatch.chdir(Path(__file__).parents[2])
with pytest.raises(ValueError, match="Unsupported database dialect"):
command.upgrade(alembic_cfg, "head")
def test_alembic_offline_path_rejects_unsupported_dialects(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
):
alembic_cfg = AlembicConfig(str(Path(__file__).parents[1] / "alembic.ini"))
alembic_cfg.set_main_option("sqlalchemy.url", "mysql://user@localhost/sales_trainer")
monkeypatch.chdir(Path(__file__).parents[2])
with pytest.raises(ValueError, match="Unsupported database dialect"):
command.upgrade(alembic_cfg, "head", sql=True)
assert capsys.readouterr().out == ""
def test_alembic_sqlite_offline_render_includes_audit_constraint(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
):
alembic_cfg = AlembicConfig(str(Path(__file__).parents[1] / "alembic.ini"))
alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{tmp_path / 'offline.db'}")
monkeypatch.chdir(Path(__file__).parents[2])
command.upgrade(alembic_cfg, "head", sql=True)
rendered = capsys.readouterr().out
assert "ck_audit_actor_requires_org" in rendered
assert "CREATE TABLE _alembic_tmp_audit_events" in rendered
command.downgrade(alembic_cfg, "head:base", sql=True)
downgrade_rendered = capsys.readouterr().out
assert "DROP TABLE _alembic_tmp_audit_events" not in downgrade_rendered
assert "_alembic_tmp_audit_events" in downgrade_rendered
def test_alembic_migration_up_and_down(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
database_url = f"sqlite:///{tmp_path / 'migration.db'}"
alembic_cfg = AlembicConfig(str(Path(__file__).parents[1] / "alembic.ini"))
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
monkeypatch.chdir(Path(__file__).parents[2])
command.upgrade(alembic_cfg, "head")
engine = create_db_engine(database_url)
assert EXPECTED_TABLES <= set(inspect(engine).get_table_names())
with engine.connect() as connection:
assert connection.execute(text("PRAGMA foreign_keys")).scalar() == 1
command.downgrade(alembic_cfg, "base")
assert not (EXPECTED_TABLES & set(inspect(engine).get_table_names()))