"""Tests for migration g7h8i9j0k1l2 (backsweep orphaned memory_units). Uses a dedicated pg0 instance (port 5562) so the test can control exactly which migrations have run before inserting the orphan seed data. """ import asyncio import uuid from pathlib import Path import pytest from alembic import command from alembic.config import Config from sqlalchemy import create_engine, text # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic") def _alembic_cfg(db_url: str) -> Config: cfg = Config() cfg.set_main_option("script_location", _SCRIPT_LOCATION) cfg.set_main_option("sqlalchemy.url", db_url) cfg.set_main_option("prepend_sys_path", ".") cfg.set_main_option("path_separator", "os") return cfg def _upgrade(db_url: str, revision: str) -> None: command.upgrade(_alembic_cfg(db_url), revision) # --------------------------------------------------------------------------- # Fixture: fresh database at the revision just before the backsweep # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def pre_backsweep_db_url(): """ Spin up a dedicated pg0 instance and run all migrations up to (but not including) the backsweep revision so each test can seed orphan data and then apply the backsweep itself. """ from hindsight_api.pg0 import EmbeddedPostgres pg0 = EmbeddedPostgres(name="hindsight-backsweep-test", port=5562) loop = asyncio.new_event_loop() try: url = loop.run_until_complete(pg0.ensure_running()) finally: loop.close() # Migrate up to the revision just before the backsweep. _upgrade(url, "f6g7h8i9j0k1") return url # --------------------------------------------------------------------------- # The test # --------------------------------------------------------------------------- def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url): """ Seed four kinds of rows then apply the backsweep migration and verify: Rows that MUST be deleted ───────────────────────── A. Any fact_type, bank_id missing from banks → Pass 1 deletes these regardless of fact_type or source links. B. observation, bank exists, but ALL source_memory_ids are gone → Pass 2 deletes these. Rows that MUST survive ────────────────────── C. observation, bank exists, at least ONE source_memory_id still live → Pass 2 must not touch these. D. Non-observation (world), bank exists, no sources (not relevant) → Pass 1 must not touch these (bank exists). """ db_url = pre_backsweep_db_url engine = create_engine(db_url) alive_bank = f"bank_{uuid.uuid4().hex[:8]}" ghost_bank = f"bank_{uuid.uuid4().hex[:8]}" # never inserted into banks # UUIDs for memory units id_pass1_world = uuid.uuid4() # A: world unit, ghost bank id_pass1_obs = uuid.uuid4() # A: observation, ghost bank id_pass2_obs = uuid.uuid4() # B: observation, all sources gone id_keep_obs = uuid.uuid4() # C: observation with one live source id_keep_world = uuid.uuid4() # D: world unit, alive bank id_live_source = uuid.uuid4() # live source for C with engine.connect() as conn: # --- banks --- conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": alive_bank}) # --- seed memory_units --- def insert_mu(uid, bank, fact_type, sources=None): src_arr = "{" + ",".join(str(s) for s in (sources or [])) + "}" conn.execute( text( """ INSERT INTO memory_units (id, bank_id, text, fact_type, source_memory_ids) VALUES (:id, :bank, :text, :ft, CAST(:src AS uuid[])) """ ), {"id": uid, "bank": bank, "text": "test", "ft": fact_type, "src": src_arr}, ) # A: ghost-bank rows (Pass 1 targets) insert_mu(id_pass1_world, ghost_bank, "world") insert_mu(id_pass1_obs, ghost_bank, "observation", sources=[uuid.uuid4()]) # B: observation with all-dead sources (Pass 2 target) insert_mu(id_pass2_obs, alive_bank, "observation", sources=[uuid.uuid4(), uuid.uuid4()]) # C: observation with one live source (must survive) insert_mu(id_live_source, alive_bank, "world") insert_mu(id_keep_obs, alive_bank, "observation", sources=[id_live_source, uuid.uuid4()]) # D: world unit in alive bank (must survive) insert_mu(id_keep_world, alive_bank, "world") conn.commit() # --- apply the backsweep --- _upgrade(db_url, "g7h8i9j0k1l2") # --- verify --- with engine.connect() as conn: def exists(uid): return conn.execute( text("SELECT 1 FROM memory_units WHERE id = :id"), {"id": uid} ).fetchone() is not None # Must be gone assert not exists(id_pass1_world), "Pass 1: world unit with ghost bank should be deleted" assert not exists(id_pass1_obs), "Pass 1: observation with ghost bank should be deleted" assert not exists(id_pass2_obs), "Pass 2: observation with all-dead sources should be deleted" # Must survive assert exists(id_keep_obs), "observation with a live source must not be deleted" assert exists(id_keep_world), "world unit in alive bank must not be deleted" assert exists(id_live_source), "live source memory unit must not be deleted" engine.dispose()