291 lines
8.8 KiB
Python
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()))
|