from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from alembic import command from alembic.config import Config from sqlalchemy import inspect from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.db import Base, create_database_engine from app.models.operations import AuditLog, IdempotencyRecord, Job, JobStatus from app.security.policy import Role from app.services.idempotency import IdempotencyConflict, IdempotencyService from app.services.identity import IdentityRepository def make_session(): engine = create_database_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(engine) return engine, Session(engine) def test_operations_migrations_round_trip_and_match_models(tmp_path, monkeypatch): database_url = f"sqlite+pysqlite:///{tmp_path / 'migration-roundtrip.db'}" monkeypatch.setenv("DATABASE_URL", database_url) alembic_config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini")) alembic_config.set_main_option("sqlalchemy.url", database_url) command.upgrade(alembic_config, "head") engine = create_database_engine(database_url) try: inspector = inspect(engine) assert {"jobs", "idempotency_records", "audit_logs"}.issubset( set(inspector.get_table_names()) ) job_columns = {column["name"] for column in inspector.get_columns("jobs")} assert {"message", "result", "progress_detail", "metadata"}.issubset(job_columns) job_indexes = {index["name"] for index in inspector.get_indexes("jobs")} assert {"ix_jobs_org_status_created", "ix_jobs_org_owner"}.issubset(job_indexes) job_foreign_keys = { (tuple(foreign_key["constrained_columns"]), tuple(foreign_key["referred_columns"])) for foreign_key in inspector.get_foreign_keys("jobs") } assert (("organization_id",), ("id",)) in job_foreign_keys assert (("owner_user_id",), ("id",)) in job_foreign_keys finally: engine.dispose() command.downgrade(alembic_config, "0003_memory") engine = create_database_engine(database_url) try: assert "jobs" not in set(inspect(engine).get_table_names()) finally: engine.dispose() command.upgrade(alembic_config, "head") command.check(alembic_config) def test_sqlite_engine_enables_foreign_keys(): engine = create_database_engine("sqlite+pysqlite:///:memory:") try: with engine.connect() as connection: assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar() == 1 finally: engine.dispose() def test_operations_reject_unknown_organization_reference(): engine, session = make_session() try: session.add( Job( organization_id="org_missing", operation="graph.build", status=JobStatus.QUEUED, ) ) with pytest.raises(IntegrityError): session.commit() finally: session.rollback() session.close() engine.dispose() def test_operations_enforce_owner_fk_and_delete_actions(): engine, session = make_session() try: repo = IdentityRepository(session) org = repo.create_organization(name="FK Org", slug="fk-org") user = repo.create_user(email="fk@example.com") session.commit() session.add( Job( organization_id=org.id, owner_user_id="usr_missing", operation="graph.build", status=JobStatus.QUEUED, ) ) with pytest.raises(IntegrityError): session.commit() session.rollback() user_job = Job( organization_id=org.id, owner_user_id=user.id, operation="graph.build", status=JobStatus.QUEUED, ) org_job = Job( organization_id=org.id, operation="report.generate", status=JobStatus.QUEUED, ) session.add_all([user_job, org_job]) session.commit() user_job_id = user_job.id org_job_id = org_job.id session.delete(user) session.commit() persisted_user_job = session.get(Job, user_job_id) assert persisted_user_job is not None assert persisted_user_job.owner_user_id is None session.delete(org) session.commit() assert session.get(Job, org_job_id) is None finally: session.close() engine.dispose() def seed_membership(session): repo = IdentityRepository(session) org = repo.create_organization(name="Org A", slug="ops-org") user = repo.create_user(email="ops@example.com") membership = repo.create_membership(user.id, org.id, Role.ADMIN) session.commit() return org, user, membership def test_operations_schema_has_durable_tables_and_tenant_indexes(): engine, session = make_session() try: tables = set(inspect(engine).get_table_names()) assert {"jobs", "idempotency_records", "audit_logs"}.issubset(tables) assert any( "organization_id" in index["column_names"] for index in inspect(engine).get_indexes("jobs") ) finally: session.close() engine.dispose() def test_job_and_audit_records_keep_org_and_owner_scope(): engine, session = make_session() try: org, user, _membership = seed_membership(session) job = Job( organization_id=org.id, owner_user_id=user.id, operation="graph.build", status=JobStatus.QUEUED, ) audit = AuditLog( organization_id=org.id, actor_user_id=user.id, action="job.created", target_type="job", target_id=job.id, details={"safe": True}, ) session.add_all([job, audit]) session.commit() assert job.organization_id == org.id assert job.owner_user_id == user.id assert audit.organization_id == org.id assert audit.details == {"safe": True} finally: session.close() engine.dispose() def test_idempotency_reservation_replays_same_request_and_rejects_hash_conflict(): engine, session = make_session() try: org, user, _membership = seed_membership(session) service = IdempotencyService(session) first = service.reserve( organization_id=org.id, user_id=user.id, key="key-1", request_body={"project_name": "A"}, ) session.commit() assert first.is_new is True replay = service.reserve( organization_id=org.id, user_id=user.id, key="key-1", request_body={"project_name": "A"}, ) assert replay.is_new is False assert replay.record.id == first.record.id try: service.reserve( organization_id=org.id, user_id=user.id, key="key-1", request_body={"project_name": "B"}, ) except IdempotencyConflict: pass else: raise AssertionError("same idempotency key accepted a different request") finally: session.close() engine.dispose() def test_idempotency_is_tenant_and_user_scoped(): engine, session = make_session() try: repo = IdentityRepository(session) org_a = repo.create_organization(name="A", slug="org-a-ops") org_b = repo.create_organization(name="B", slug="org-b-ops") user_a = repo.create_user(email="a-ops@example.com") user_b = repo.create_user(email="b-ops@example.com") repo.create_membership(user_a.id, org_a.id, Role.ADMIN) repo.create_membership(user_b.id, org_b.id, Role.ADMIN) session.commit() service = IdempotencyService(session) a = service.reserve( organization_id=org_a.id, user_id=user_a.id, key="shared-key", request_body={"x": 1}, ) b = service.reserve( organization_id=org_b.id, user_id=user_b.id, key="shared-key", request_body={"x": 1}, ) assert a.is_new is True assert b.is_new is True assert a.record.id != b.record.id finally: session.close() engine.dispose()