import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from scripts.move_session_workspace import ( MoveError, _can_defer_constraints, # pyright: ignore[reportPrivateUsage] _count, # pyright: ignore[reportPrivateUsage] _resolve_strategy, # pyright: ignore[reportPrivateUsage] plan_moves, ) from scripts.move_session_workspace import ( _session_row as _session_row_helper, # pyright: ignore[reportPrivateUsage] ) from src import models async def _mk_workspace(db: AsyncSession, name: str) -> None: db.add(models.Workspace(name=name)) await db.flush() async def _mk_session_with_messages( db: AsyncSession, ws: str, name: str, peer: str, n: int ) -> None: db.add(models.Peer(name=peer, workspace_name=ws)) db.add(models.Session(name=name, workspace_name=ws)) await db.flush() for i in range(n): db.add( models.Message( session_name=name, workspace_name=ws, peer_name=peer, content=f"m{i}", seq_in_session=i, ) ) await db.flush() @pytest.mark.asyncio async def test_plan_clean_session_counts(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") await _mk_session_with_messages(db_session, "personal", "s1", "robsherman", 3) plans = await plan_moves(db_session, "personal", "highway", ["s1"]) assert len(plans) == 1 assert plans[0].source_name == "s1" assert plans[0].target_name == "s1" assert plans[0].renamed is False assert plans[0].messages == 3 @pytest.mark.asyncio async def test_plan_rejects_same_workspace(db_session: AsyncSession): await _mk_workspace(db_session, "personal") with pytest.raises(MoveError, match="same workspace"): await plan_moves(db_session, "personal", "personal", ["s1"]) @pytest.mark.asyncio async def test_plan_rejects_missing_session(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") with pytest.raises(MoveError, match="not found"): await plan_moves(db_session, "personal", "highway", ["nope"]) @pytest.mark.asyncio async def test_plan_renames_on_collision(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") await _mk_session_with_messages(db_session, "personal", "maca", "robsherman", 2) await _mk_session_with_messages( db_session, "highway", "maca", "robsherman", 5 ) # collision plans = await plan_moves(db_session, "personal", "highway", ["maca"]) assert plans[0].target_name == "maca-from-personal" assert plans[0].renamed is True @pytest.mark.asyncio async def test_plan_skip_mode_leaves_collision(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") await _mk_session_with_messages(db_session, "personal", "maca", "robsherman", 2) await _mk_session_with_messages(db_session, "highway", "maca", "robsherman", 5) plans = await plan_moves( db_session, "personal", "highway", ["maca"], on_collision="skip" ) assert plans == [] # skipped, nothing to do @pytest.mark.asyncio async def test_ensure_dependencies_copies_missing_peer_fullcolumn( db_session: AsyncSession, ): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") # peer with metadata in personal; session uses it db_session.add( models.Peer( name="robsherman", workspace_name="personal", internal_metadata={"card": "x"}, ) ) db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() db_session.add( models.Message( session_name="s1", workspace_name="personal", peer_name="robsherman", content="hi", seq_in_session=0, ) ) await db_session.flush() from scripts.move_session_workspace import ensure_dependencies created_peers, _ = await ensure_dependencies( db_session, "personal", "highway", "s1" ) await db_session.flush() assert created_peers == ["robsherman"] moved = await db_session.scalar( select(models.Peer).where( models.Peer.workspace_name == "highway", models.Peer.name == "robsherman", ) ) assert moved is not None assert moved.internal_metadata == { "card": "x" } # full-column copy preserved peer card @pytest.mark.asyncio async def test_ensure_dependencies_leaves_existing_peer_untouched( db_session: AsyncSession, ): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") db_session.add( models.Peer( name="robsherman", workspace_name="personal", internal_metadata={"card": "SOURCE"}, ) ) db_session.add( models.Peer( name="robsherman", workspace_name="highway", internal_metadata={"card": "TARGET"}, ) ) db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() db_session.add( models.Message( session_name="s1", workspace_name="personal", peer_name="robsherman", content="hi", seq_in_session=0, ) ) await db_session.flush() from scripts.move_session_workspace import ensure_dependencies created_peers, _ = await ensure_dependencies( db_session, "personal", "highway", "s1" ) await db_session.flush() assert created_peers == [] # already present, not created existing = await db_session.scalar( select(models.Peer).where( models.Peer.workspace_name == "highway", models.Peer.name == "robsherman", ) ) assert existing is not None assert existing.internal_metadata == {"card": "TARGET"} # NOT clobbered @pytest.mark.asyncio async def test_relocate_preserves_id_and_moves_children(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") db_session.add( models.Peer(name="robsherman", workspace_name="highway") ) # target peer exists db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() src_sess = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "personal", models.Session.name == "s1", ) ) assert src_sess is not None src_id, src_created = src_sess.id, src_sess.created_at db_session.add(models.Peer(name="robsherman", workspace_name="personal")) await db_session.flush() db_session.add( models.Message( session_name="s1", workspace_name="personal", peer_name="robsherman", content="hi", seq_in_session=0, ) ) await db_session.flush() from scripts.move_session_workspace import ensure_dependencies, relocate_in_place await ensure_dependencies(db_session, "personal", "highway", "s1") await relocate_in_place(db_session, "personal", "highway", "s1", "s1") await db_session.flush() moved = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "highway", models.Session.name == "s1", ) ) assert moved is not None assert moved.id == src_id # id preserved assert moved.created_at == src_created assert await _count(db_session, models.Message, "highway", "s1") == 1 assert await _count(db_session, models.Message, "personal", "s1") == 0 # no orphaned source session row assert await _session_row_helper(db_session, "personal", "s1") is None @pytest.mark.asyncio async def test_clear_queue_deletes_rows(db_session: AsyncSession): await _mk_workspace(db_session, "personal") db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() # seed a processed queue row for the session sess = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "personal", models.Session.name == "s1" ) ) assert sess is not None db_session.add( models.QueueItem( session_id=sess.id, workspace_name="personal", work_unit_key="test-key-1", task_type="representation", payload={}, processed=True, ) ) await db_session.flush() from scripts.move_session_workspace import clear_session_queue deleted = await clear_session_queue(db_session, "personal", "s1", force=False) await db_session.flush() assert deleted == 1 @pytest.mark.asyncio async def test_clear_queue_raises_on_pending_without_force(db_session: AsyncSession): await _mk_workspace(db_session, "personal") db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() sess = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "personal", models.Session.name == "s1" ) ) assert sess is not None db_session.add( models.QueueItem( session_id=sess.id, workspace_name="personal", work_unit_key="test-key-2", task_type="representation", payload={}, processed=False, ) ) await db_session.flush() from scripts.move_session_workspace import clear_session_queue with pytest.raises(MoveError, match="pending queue items"): await clear_session_queue(db_session, "personal", "s1", force=False) @pytest.mark.asyncio async def test_clear_queue_force_deletes_pending(db_session: AsyncSession): await _mk_workspace(db_session, "personal") db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() sess = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "personal", models.Session.name == "s1" ) ) assert sess is not None db_session.add( models.QueueItem( session_id=sess.id, workspace_name="personal", work_unit_key="test-key-3", task_type="representation", payload={}, processed=False, ) ) await db_session.flush() from scripts.move_session_workspace import clear_session_queue deleted = await clear_session_queue(db_session, "personal", "s1", force=True) await db_session.flush() assert deleted == 1 @pytest.mark.asyncio async def test_apply_then_integrity_clean(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") await _mk_session_with_messages(db_session, "personal", "s1", "robsherman", 2) from scripts.move_session_workspace import apply_moves, plan_moves plans = await plan_moves(db_session, "personal", "highway", ["s1"]) await apply_moves(db_session, "personal", "highway", plans, force_clear_queue=True) await db_session.flush() assert await _count(db_session, models.Message, "highway", "s1") == 2 assert await _count(db_session, models.Message, "personal", "s1") == 0 @pytest.mark.asyncio async def test_dry_run_writes_nothing(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") await _mk_session_with_messages(db_session, "personal", "s1", "robsherman", 2) from scripts.move_session_workspace import plan_moves before = await _count(db_session, models.Message, "personal", "s1") await plan_moves(db_session, "personal", "highway", ["s1"]) # plan only, no apply after = await _count(db_session, models.Message, "personal", "s1") assert before == after == 2 # plan_moves is read-only def test_build_parser_defaults(): from scripts.move_session_workspace import build_parser args = build_parser().parse_args( ["--from", "personal", "--to", "highway", "--session", "s1"] ) assert args.source == "personal" and args.target == "highway" assert args.session == ["s1"] assert args.apply is False # dry-run default assert args.on_collision == "rename" @pytest.mark.asyncio async def test_cross_boundary_premises_flags_only_outside_move_set( db_session: AsyncSession, ): await _mk_workspace(db_session, "personal") db_session.add(models.Peer(name="p", workspace_name="personal")) for s in ("a", "b", "other"): db_session.add(models.Session(name=s, workspace_name="personal")) await db_session.flush() # Collection required by Document FK (observer, observed, workspace_name) db_session.add( models.Collection(observer="p", observed="p", workspace_name="personal") ) await db_session.flush() # premise docs prem_co = models.Document( workspace_name="personal", session_name="b", observer="p", observed="p", content="co", source_ids=[], ) prem_out = models.Document( workspace_name="personal", session_name="other", observer="p", observed="p", content="out", source_ids=[], ) db_session.add_all([prem_co, prem_out]) await db_session.flush() # a conclusion in session "a" citing both premises db_session.add( models.Document( workspace_name="personal", session_name="a", observer="p", observed="p", content="concl", source_ids=[prem_co.id, prem_out.id], ) ) await db_session.flush() from scripts.move_session_workspace import cross_boundary_premises flagged = await cross_boundary_premises(db_session, "personal", {"a", "b"}) assert prem_out.id in flagged # "other" is outside the move set → flagged assert prem_co.id not in flagged # "b" is co-moved → not flagged @pytest.mark.asyncio async def test_relocate_create_new_repoints_and_deletes_old(db_session: AsyncSession): await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") db_session.add(models.Peer(name="robsherman", workspace_name="highway")) db_session.add(models.Peer(name="robsherman", workspace_name="personal")) db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() old = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "personal", models.Session.name == "s1" ) ) assert old is not None old_id = old.id db_session.add( models.Message( session_name="s1", workspace_name="personal", peer_name="robsherman", content="hi", seq_in_session=0, ) ) await db_session.flush() from scripts.move_session_workspace import relocate_create_new await relocate_create_new(db_session, "personal", "highway", "s1", "s1") await db_session.flush() moved = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "highway", models.Session.name == "s1" ) ) assert moved is not None and moved.id != old_id # id churns on fallback assert await _count(db_session, models.Message, "highway", "s1") == 1 assert await _session_row_helper(db_session, "personal", "s1") is None @pytest.mark.asyncio async def test_plan_populates_create_lists_and_queue_count(db_session: AsyncSession): """plan_moves populates peers_to_create and queue_rows on the SessionPlan.""" await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") # Create the source peer+session in personal; highway has NO peer yet (absent) db_session.add(models.Peer(name="robsherman", workspace_name="personal")) db_session.add(models.Session(name="s1", workspace_name="personal")) await db_session.flush() db_session.add( models.Message( session_name="s1", workspace_name="personal", peer_name="robsherman", content="hi", seq_in_session=0, ) ) await db_session.flush() # Seed a queue row for the source session sess = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "personal", models.Session.name == "s1" ) ) assert sess is not None db_session.add( models.QueueItem( session_id=sess.id, workspace_name="personal", work_unit_key="plan-test-key", task_type="representation", payload={}, processed=True, ) ) await db_session.flush() plans = await plan_moves(db_session, "personal", "highway", ["s1"]) assert len(plans) == 1 plan = plans[0] assert "robsherman" in plan.peers_to_create # absent in target → needs creation assert plan.queue_rows == 1 # the queue row we seeded @pytest.mark.asyncio async def test_apply_rename_path_end_to_end(db_session: AsyncSession): """apply_moves renames the moved session when a same-named session exists in target.""" await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") # Both workspaces have the peer db_session.add(models.Peer(name="robsherman", workspace_name="personal")) db_session.add(models.Peer(name="robsherman", workspace_name="highway")) # "maca" session in personal (source) with 3 messages db_session.add(models.Session(name="maca", workspace_name="personal")) # Pre-existing "maca" in highway (the collision) with 5 messages db_session.add(models.Session(name="maca", workspace_name="highway")) await db_session.flush() for i in range(3): db_session.add( models.Message( session_name="maca", workspace_name="personal", peer_name="robsherman", content=f"personal-m{i}", seq_in_session=i, ) ) for i in range(5): db_session.add( models.Message( session_name="maca", workspace_name="highway", peer_name="robsherman", content=f"highway-m{i}", seq_in_session=i, ) ) await db_session.flush() from scripts.move_session_workspace import apply_moves plans = await plan_moves(db_session, "personal", "highway", ["maca"]) # The plan should rename due to collision assert len(plans) == 1 assert plans[0].renamed is True assert plans[0].target_name == "maca-from-personal" await apply_moves(db_session, "personal", "highway", plans, force_clear_queue=True) await db_session.flush() # Moved session exists under renamed name in highway renamed_sess = await _session_row_helper( db_session, "highway", "maca-from-personal" ) assert renamed_sess is not None # Moved messages are now under the renamed session in highway assert ( await _count(db_session, models.Message, "highway", "maca-from-personal") == 3 ) # Original personal session is gone assert await _session_row_helper(db_session, "personal", "maca") is None assert await _count(db_session, models.Message, "personal", "maca") == 0 # Pre-existing highway "maca" session is untouched with its original 5 messages original_highway = await _session_row_helper(db_session, "highway", "maca") assert original_highway is not None assert await _count(db_session, models.Message, "highway", "maca") == 5 def test_cli_runs_as_script_without_name_error(): """Regression: running the module as a script must not NameError because a function (plan_moves) is defined after the __main__ guard. --from==--to hits the same-workspace guard before any DB query, so this needs no database.""" import os import subprocess import sys repo = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) script = os.path.join(repo, "scripts", "move_session_workspace.py") result = subprocess.run( [sys.executable, script, "--from", "w", "--to", "w", "--session", "s"], capture_output=True, text=True, cwd=repo, ) combined = result.stdout + result.stderr assert "NameError" not in combined, combined assert "same workspace" in combined, combined @pytest.mark.asyncio async def test_assert_integrity_ignores_peer_global_documents(db_session: AsyncSession): """Regression: peer-global documents (session_name IS NULL) are legitimately session-less and must NOT be flagged as orphans by _assert_integrity.""" from scripts.move_session_workspace import ( _assert_integrity, # pyright: ignore[reportPrivateUsage] ) await _mk_workspace(db_session, "wsgi") db_session.add(models.Peer(name="p", workspace_name="wsgi")) await db_session.flush() # peer must exist before the collection FK db_session.add(models.Collection(observer="p", observed="p", workspace_name="wsgi")) await db_session.flush() db_session.add( models.Document( workspace_name="wsgi", session_name=None, # peer-global observer="p", observed="p", content="global fact", source_ids=[], ) ) await db_session.flush() # Must NOT raise (would raise "orphaned rows in documents" before the fix). await _assert_integrity(db_session, "wsgi") def test_build_parser_strategy_default(): """--strategy defaults to 'auto'; explicit --strategy create_new is accepted.""" from scripts.move_session_workspace import build_parser args_default = build_parser().parse_args( ["--from", "personal", "--to", "highway", "--session", "s1"] ) assert args_default.strategy == "auto" args_explicit = build_parser().parse_args( [ "--from", "personal", "--to", "highway", "--session", "s1", "--strategy", "create_new", ] ) assert args_explicit.strategy == "create_new" @pytest.mark.asyncio async def test_can_defer_constraints_true_in_test_env(db_session: AsyncSession): """The test role can alter constraints (owner or superuser) so this returns True. The in-place relocation tests already prove the role can ALTER TABLE … ALTER CONSTRAINT, so the probe must agree. """ result = await _can_defer_constraints(db_session) assert result is True @pytest.mark.asyncio async def test_resolve_strategy_auto_picks_in_place(db_session: AsyncSession): """With a privileged test role, auto-detection resolves to 'in_place'.""" resolved = await _resolve_strategy(db_session, "auto") assert resolved == "in_place" @pytest.mark.asyncio async def test_resolve_strategy_rejects_unknown(db_session: AsyncSession): """An unrecognised strategy string must raise MoveError immediately.""" with pytest.raises(MoveError, match="unknown strategy"): await _resolve_strategy(db_session, "bogus") @pytest.mark.asyncio async def test_apply_moves_create_new_strategy(db_session: AsyncSession): """apply_moves with strategy='create_new' moves session to target with a new id.""" await _mk_workspace(db_session, "personal") await _mk_workspace(db_session, "highway") await _mk_session_with_messages(db_session, "personal", "s1", "robsherman", 2) # Capture the original session id before the move old_sess = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "personal", models.Session.name == "s1", ) ) assert old_sess is not None old_id = old_sess.id from scripts.move_session_workspace import apply_moves plans = await plan_moves(db_session, "personal", "highway", ["s1"]) await apply_moves( db_session, "personal", "highway", plans, force_clear_queue=False, strategy="create_new", ) await db_session.flush() # Session exists in target moved = await db_session.scalar( select(models.Session).where( models.Session.workspace_name == "highway", models.Session.name == "s1", ) ) assert moved is not None assert moved.id != old_id # id must churn with create_new # Messages followed assert await _count(db_session, models.Message, "highway", "s1") == 2 assert await _count(db_session, models.Message, "personal", "s1") == 0 # Source session row is gone assert await _session_row_helper(db_session, "personal", "s1") is None