"""TDD RED gate: durable product persistence for projects, simulations, and reports. The legacy ProjectManager/report/simulation managers persist to the filesystem. This gate proves the durable SQL models + repository exist and enforce tenant/owner scope, so product resources can migrate off filesystem state without losing cross-tenant isolation. """ from pathlib import Path import pytest from alembic import command from alembic.config import Config as AlembicConfig from sqlalchemy import create_engine, inspect from app.db import Base, create_database_engine, create_session_factory from app.models.product import ( DurableReport, ProductProject, ProductSimulation, ProjectStatus, ReportStatus, SimulationStatus, ) from app.services.product_repository import ProductRepository @pytest.fixture() def session_factory(): engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(engine) factory = create_session_factory(engine) try: yield factory finally: engine.dispose() def test_product_project_persists_tenant_and_owner_scope(session_factory): session = session_factory() try: project = ProductProject( organization_id="org-a", owner_user_id="user-1", name="Q3 Brand Study", status=ProjectStatus.CREATED, language="en", ) session.add(project) session.commit() session.refresh(project) assert project.id assert project.organization_id == "org-a" assert project.owner_user_id == "user-1" fetched = session.get(ProductProject, project.id) assert fetched is not None assert fetched.name == "Q3 Brand Study" assert fetched.status == ProjectStatus.CREATED.value finally: session.close() def test_product_project_lists_only_its_tenant(session_factory): session = session_factory() try: session.add_all( [ ProductProject(organization_id="org-a", owner_user_id="user-1", name="A"), ProductProject(organization_id="org-a", owner_user_id="user-2", name="B"), ProductProject(organization_id="org-b", owner_user_id="user-1", name="C"), ] ) session.commit() rows = ( session.query(ProductProject) .filter(ProductProject.organization_id == "org-a") .all() ) assert {row.name for row in rows} == {"A", "B"} finally: session.close() def test_product_simulation_scopes_to_project_and_creator(session_factory): session = session_factory() try: project = ProductProject( organization_id="org-a", owner_user_id="user-1", name="P" ) session.add(project) session.flush() sim = ProductSimulation( organization_id="org-a", project_id=project.id, created_by_user_id="user-1", status=SimulationStatus.READY, config={"graph_id": "graph-1"}, ) session.add(sim) session.commit() session.refresh(sim) assert sim.id fetched = session.get(ProductSimulation, sim.id) assert fetched.project_id == project.id assert fetched.config["graph_id"] == "graph-1" finally: session.close() def test_durable_report_scopes_to_project_simulation_and_creator(session_factory): session = session_factory() try: project = ProductProject( organization_id="org-a", owner_user_id="user-1", name="P" ) session.add(project) session.flush() sim = ProductSimulation( organization_id="org-a", project_id=project.id, created_by_user_id="user-1", status=SimulationStatus.READY, config={}, ) session.add(sim) session.flush() report = DurableReport( organization_id="org-a", project_id=project.id, simulation_id=sim.id, created_by_user_id="user-1", status=ReportStatus.DRAFT, title="Evidence Report", ) session.add(report) session.commit() session.refresh(report) fetched = session.get(DurableReport, report.id) assert fetched is not None assert fetched.title == "Evidence Report" assert fetched.status == ReportStatus.DRAFT.value finally: session.close() def test_product_project_rejects_conflicting_tenant_lookup(session_factory): session = session_factory() try: project = ProductProject( organization_id="org-a", owner_user_id="user-1", name="Secret" ) session.add(project) session.commit() # A repository-level lookup must be tenant-scoped: an org-b caller cannot # resolve an org-a project id. row = ( session.query(ProductProject) .filter( ProductProject.id == project.id, ProductProject.organization_id == "org-b", ) .first() ) assert row is None finally: session.close() def test_product_migrations_round_trip_and_match_models(tmp_path, monkeypatch): database_url = f"sqlite+pysqlite:///{tmp_path / 'product-roundtrip.db'}" monkeypatch.setenv("DATABASE_URL", database_url) alembic_config = AlembicConfig(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) table_names = set(inspector.get_table_names()) assert {"projects", "simulations", "reports"}.issubset(table_names) project_columns = {column["name"] for column in inspector.get_columns("projects")} assert { "organization_id", "owner_user_id", "name", "status", "language", "ontology", }.issubset(project_columns) project_fks = { (tuple(fk["constrained_columns"]), tuple(fk["referred_columns"])) for fk in inspector.get_foreign_keys("projects") } assert (("organization_id",), ("id",)) in project_fks assert (("owner_user_id",), ("id",)) in project_fks simulation_columns = {column["name"] for column in inspector.get_columns("simulations")} assert {"project_id", "created_by_user_id", "config", "status"}.issubset( simulation_columns ) report_columns = {column["name"] for column in inspector.get_columns("reports")} assert { "project_id", "simulation_id", "created_by_user_id", "title", "markdown_content", }.issubset(report_columns) finally: engine.dispose() command.downgrade(alembic_config, "0006_job_metadata") engine = create_database_engine(database_url) try: table_names = set(inspect(engine).get_table_names()) assert "projects" not in table_names assert "simulations" not in table_names assert "reports" not in table_names finally: engine.dispose() command.upgrade(alembic_config, "head") command.check(alembic_config) def test_product_repository_scoped_project_crud(session_factory): session = session_factory() try: repo = ProductRepository(session) project = repo.create_project( organization_id="org-a", owner_user_id="user-1", name="Repo Project" ) assert project.organization_id == "org-a" assert project.name == "Repo Project" # Org-a can read it back by id. fetched = repo.get_project(project.id, organization_id="org-a") assert fetched is not None assert fetched.name == "Repo Project" # Org-b cannot read an org-a project id (tenant isolation). assert repo.get_project(project.id, organization_id="org-b") is None # Org-a owner list returns it. listed = repo.list_projects(organization_id="org-a", owner_user_id="user-1") assert [p.id for p in listed] == [project.id] # Another owner in the same org does not see it in their list. assert repo.list_projects(organization_id="org-a", owner_user_id="user-2") == [] finally: session.close() def test_product_repository_scoped_simulation_and_report(session_factory): session = session_factory() try: repo = ProductRepository(session) project = repo.create_project( organization_id="org-a", owner_user_id="user-1", name="P" ) sim = repo.create_simulation( organization_id="org-a", project_id=project.id, created_by_user_id="user-1", config={"graph_id": "g1"}, ) assert repo.get_simulation(sim.id, organization_id="org-b") is None assert repo.get_simulation(sim.id, organization_id="org-a") is not None report = repo.create_report( organization_id="org-a", project_id=project.id, simulation_id=sim.id, created_by_user_id="user-1", title="R", ) assert repo.get_report(report.id, organization_id="org-b") is None assert repo.get_report(report.id, organization_id="org-a") is not None finally: session.close() def test_sync_project_state_to_durable(session_factory): """Dual-write bridge: a legacy filesystem Project dict is mirrored to durable SQL.""" from types import SimpleNamespace session = session_factory() try: repo = ProductRepository(session) # Simulate the legacy ProjectManager.to_dict() output shape. legacy_state = SimpleNamespace( project_id="project-legacy", name="Legacy Project", organization_id="org-a", owner_user_id="user-1", status="created", language="en", total_text_length=120, ontology={"entity_types": ["Person"]}, simulation_requirement="Understand the market.", graph_id=None, graph_build_task_id=None, error=None, ) durable = repo.sync_project(legacy_state, commit=True) assert durable.id == "project-legacy" assert durable.organization_id == "org-a" assert durable.name == "Legacy Project" assert durable.ontology == {"entity_types": ["Person"]} # It is now readable through the scoped repository. fetched = repo.get_project("project-legacy", organization_id="org-a") assert fetched is not None # And tenant-isolated from org-b. assert repo.get_project("project-legacy", organization_id="org-b") is None finally: session.close() def test_sync_project_state_is_idempotent_on_repeat(session_factory): from types import SimpleNamespace session = session_factory() try: repo = ProductRepository(session) state = SimpleNamespace( project_id="project-dup", name="Project", organization_id="org-a", owner_user_id="user-1", status="graph_completed", language="en", total_text_length=0, ontology=None, simulation_requirement=None, graph_id="graph-9", graph_build_task_id="job-9", error=None, ) first = repo.sync_project(state, commit=True) second = repo.sync_project(state, commit=True) assert first.id == second.id rows = ( session.query(ProductProject) .filter(ProductProject.id == "project-dup") .all() ) assert len(rows) == 1 assert rows[0].graph_id == "graph-9" assert rows[0].status == "graph_completed" finally: session.close() def test_api_sync_helper_writes_durable_copy_via_session_factory(session_factory, monkeypatch): """The route helper mirrors a filesystem project into durable SQL.""" class FakeApp: class Extensions(dict): pass def __init__(self): self.extensions = {"crowdsight_session_factory": session_factory} from types import SimpleNamespace from app.api import graph as graph_api class FakeCurrentApp: __slots__ = ("_app",) def __init__(self, app): object.__setattr__(self, "_app", app) @property def extensions(self): return object.__getattribute__(self, "_app").extensions fake = FakeCurrentApp(FakeApp()) monkeypatch.setattr(graph_api, "current_app", fake) legacy = SimpleNamespace( project_id="project-via-route", name="Route Project", organization_id="org-a", owner_user_id="user-1", status="created", language="en", total_text_length=0, ontology=None, simulation_requirement="Understand the market.", graph_id=None, graph_build_task_id=None, error=None, ) graph_api._sync_project_to_durable(legacy) # Verify a durable row was created and is tenant-isolated. session = session_factory() try: repo = ProductRepository(session) fetched = repo.get_project("project-via-route", organization_id="org-a") assert fetched is not None assert fetched.name == "Route Project" assert repo.get_project("project-via-route", organization_id="org-b") is None finally: session.close() def test_api_sync_helper_is_safe_without_session_factory(session_factory, monkeypatch): """Non-local backend (no session factory) must not raise.""" from app.api import graph as graph_api class FakeCurrentApp: extensions = {} monkeypatch.setattr(graph_api, "current_app", FakeCurrentApp()) graph_api._sync_project_to_durable(object()) # should not raise def test_api_sync_helper_accepts_explicit_session_factory(session_factory, monkeypatch): """Background-thread callers pass the captured session_factory explicitly.""" from types import SimpleNamespace from app.api import graph as graph_api class FakeCurrentApp: extensions = {} # No current_app factory, so only the explicit argument can work. monkeypatch.setattr(graph_api, "current_app", FakeCurrentApp()) legacy = SimpleNamespace( project_id="project-thread", name="Thread Project", organization_id="org-a", owner_user_id="user-1", status="graph_completed", language="en", total_text_length=0, ontology=None, simulation_requirement=None, graph_id="graph-thread-1", graph_build_task_id=None, error=None, ) graph_api._sync_project_to_durable(legacy, session_factory) session = session_factory() try: repo = ProductRepository(session) fetched = repo.get_project("project-thread", organization_id="org-a") assert fetched is not None assert fetched.graph_id == "graph-thread-1" finally: session.close() def test_sync_simulation_state_to_durable(session_factory): """Legacy simulation state is mirrored to durable SQL and tenant-isolated.""" from types import SimpleNamespace session = session_factory() try: repo = ProductRepository(session) project = repo.create_project( organization_id="org-a", owner_user_id="user-1", name="P" ) legacy = SimpleNamespace( simulation_id="sim-legacy", project_id=project.id, graph_id="graph-1", organization_id="org-a", owner_user_id="user-1", status="ready", platform="parallel", config={"graph_id": "graph-1"}, current_round=2, ) durable = repo.sync_simulation(legacy, commit=True) assert durable.id == "sim-legacy" assert durable.project_id == project.id assert repo.get_simulation("sim-legacy", organization_id="org-a") is not None assert repo.get_simulation("sim-legacy", organization_id="org-b") is None finally: session.close() def test_api_sync_simulation_helper_writes_durable_copy(session_factory, monkeypatch): """The simulation route helper mirrors a legacy state via session factory.""" from types import SimpleNamespace from app.services.simulation_manager import SimulationState, SimulationStatus from app.api import simulation as sim_api class FakeCurrentApp: extensions = {"crowdsight_session_factory": session_factory} monkeypatch.setattr(sim_api, "current_app", FakeCurrentApp()) session = session_factory() try: repo = ProductRepository(session) project = repo.create_project( organization_id="org-a", owner_user_id="user-1", name="P" ) state = SimulationState( simulation_id="sim-via-route", project_id=project.id, graph_id="graph-1", enable_twitter=False, enable_reddit=True, status=SimulationStatus.READY, ) sim_api._sync_simulation_to_durable( state, organization_id="org-a", project_id=project.id, created_by_user_id="user-1", ) fetched = repo.get_simulation("sim-via-route", organization_id="org-a") assert fetched is not None assert fetched.project_id == project.id # A stranger tenant cannot read it. assert repo.get_simulation("sim-via-route", organization_id="org-b") is None finally: session.close() def test_sync_report_state_to_durable(session_factory): """Legacy report state is mirrored to durable SQL and tenant-isolated.""" from types import SimpleNamespace session = session_factory() try: repo = ProductRepository(session) project = repo.create_project( organization_id="org-a", owner_user_id="user-1", name="P" ) sim = repo.create_simulation( organization_id="org-a", project_id=project.id, created_by_user_id="user-1", config={}, ) legacy = SimpleNamespace( report_id="report-legacy", status="completed", title="Local Report", markdown_content="# Report\nEvidence.", outline={"sections": ["Evidence"]}, error=None, ) durable = repo.sync_report( legacy, organization_id="org-a", project_id=project.id, simulation_id=sim.id, created_by_user_id="user-1", commit=True, ) assert durable.id == "report-legacy" assert durable.title == "Local Report" assert "Evidence." in durable.markdown_content assert repo.get_report("report-legacy", organization_id="org-a") is not None assert repo.get_report("report-legacy", organization_id="org-b") is None finally: session.close() def test_api_sync_report_helper_writes_durable_copy(session_factory, monkeypatch): """The report route helper mirrors a legacy report via session factory.""" from types import SimpleNamespace from app.api import report as report_api class FakeCurrentApp: extensions = {"crowdsight_session_factory": session_factory} monkeypatch.setattr(report_api, "current_app", FakeCurrentApp()) session = session_factory() try: repo = ProductRepository(session) project = repo.create_project( organization_id="org-a", owner_user_id="user-1", name="P" ) sim = repo.create_simulation( organization_id="org-a", project_id=project.id, created_by_user_id="user-1", config={}, ) legacy = SimpleNamespace( report_id="report-via-route", status="completed", title="Route Report", markdown_content="Body.", outline=None, error=None, ) report_api._sync_report_to_durable( legacy, organization_id="org-a", project_id=project.id, simulation_id=sim.id, created_by_user_id="user-1", ) fetched = repo.get_report("report-via-route", organization_id="org-a") assert fetched is not None assert fetched.title == "Route Report" assert repo.get_report("report-via-route", organization_id="org-b") is None finally: session.close() def test_scoped_project_reads_from_durable_first(session_factory, monkeypatch): """Read cutover: scoped_project resolves from durable SQL, not filesystem.""" from types import SimpleNamespace from app.config import Config from app.security import resources as res old_backend = Config.MEMORY_BACKEND monkeypatch.setattr(Config, "MEMORY_BACKEND", "local") session = session_factory() try: repo = ProductRepository(session) durable = repo.create_project( organization_id="org-a", owner_user_id="user-1", name="Durable Project" ) session.commit() class FakeCurrentApp: extensions = {"crowdsight_session_factory": session_factory} monkeypatch.setattr(res, "current_app", FakeCurrentApp()) monkeypatch.setattr( res, "current_actor", lambda: SimpleNamespace( organization_id="org-a", user_id="user-1", role="user", ), ) project = res.scoped_project(durable.id) assert project is not None assert project.project_id == durable.id assert project.name == "Durable Project" assert project.organization_id == "org-a" assert project.owner_user_id == "user-1" # A cross-tenant caller cannot resolve it. monkeypatch.setattr( res, "current_actor", lambda: SimpleNamespace( organization_id="org-b", user_id="user-9", role="user", ), ) assert res.scoped_project(durable.id) is None finally: Config.MEMORY_BACKEND = old_backend session.close() def test_scoped_project_falls_back_to_filesystem_when_no_durable( monkeypatch, tmp_path ): """Read cutover: absent durable row falls back to legacy filesystem manager.""" from types import SimpleNamespace from app.models.project import ProjectManager from app.security import resources as res original_dir = ProjectManager.PROJECTS_DIR ProjectManager.PROJECTS_DIR = str(tmp_path / "projects") try: fs_project = ProjectManager.create_project( "FS Project", organization_id="org-a", owner_user_id="user-1" ) class FakeCurrentApp: extensions = {} monkeypatch.setattr(res, "current_app", FakeCurrentApp()) monkeypatch.setattr( res, "current_actor", lambda: SimpleNamespace( organization_id="org-a", user_id="user-1", role="user", ), ) project = res.scoped_project(fs_project.project_id) assert project is not None assert project.project_id == fs_project.project_id finally: ProjectManager.PROJECTS_DIR = original_dir def test_simulation_state_save_mirrors_durable_when_scoped(session_factory, monkeypatch): """Simulation status updates dual-write when state carries tenant scope.""" from app.services.simulation_manager import ( SimulationManager, SimulationState, SimulationStatus, ) manager = SimulationManager(session_factory=session_factory) state = SimulationState( simulation_id="sim-status", project_id="project-p", graph_id="graph-g", organization_id="org-a", owner_user_id="user-1", status=SimulationStatus.RUNNING, current_round=3, ) # Trigger the same save path used during run; then confirm durable mirror. manager._save_simulation_state(state) session = session_factory() try: repo = ProductRepository(session) fetched = repo.get_simulation("sim-status", organization_id="org-a") assert fetched is not None assert fetched.status == "running" assert fetched.current_round == 3 assert fetched.organization_id == "org-a" finally: session.close() def test_simulation_state_save_skips_durable_without_scope(session_factory): """Unscoped legacy save must fall back to filesystem without raising.""" from app.services.simulation_manager import ( SimulationManager, SimulationState, SimulationStatus, ) manager = SimulationManager(session_factory=session_factory) state = SimulationState( simulation_id="sim-noscope", project_id="project-p", graph_id="graph-g", status=SimulationStatus.RUNNING, ) # No organization_id -> must not raise, and no durable row is written. manager._save_simulation_state(state) session = session_factory() try: repo = ProductRepository(session) assert repo.get_simulation("sim-noscope", organization_id="org-a") is None finally: session.close()