"""Unit tests for scripts/migrate_dynamodb_data.py (REQ-163, P4). Tests the pure item-mapping logic + table-pair resolution. The AWS I/O (scan_all/copy_items) is thin boto3 glue, not unit-tested here (covered by the dry-run path + the runbook's live verification). """ import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) import migrate_dynamodb_data as mig class TestMapItem: def test_map_item_preserves_typed_attributes(self): item = { "consumerRepo": {"S": "acdl/consumer-a"}, "contractId#submittedAt": {"S": "c-1#2026-01-01T00:00:00Z"}, "contract": {"S": "name: foo\n"}, "count": {"N": "42"}, } result = mig.map_item(item) assert result == item def test_map_item_returns_independent_copy(self): """The mapped item must not alias the scanned item (callers may mutate).""" item = {"k": {"S": "v"}} result = mig.map_item(item) result["k"]["S"] = "mutated" assert item["k"]["S"] == "v", "map_item returned an alias, not a copy" def test_map_item_empty(self): assert mig.map_item({}) == {} def test_map_item_preserves_binary_and_nested(self): item = { "pk": {"B": b"\x01\x02"}, "nested": {"M": {"a": {"S": "x"}}}, "list": {"L": [{"S": "1"}, {"S": "2"}]}, } assert mig.map_item(item) == item class TestTablePair: def test_contracts_alias(self): assert mig.table_pair_for("contracts") == ("acdl-contracts", "nova-contracts") def test_change_requests_alias(self): assert mig.table_pair_for("change-requests") == ( "acdl-change-requests", "nova-change-requests" ) def test_literal_source_name(self): assert mig.table_pair_for("acdl-contracts") == ("acdl-contracts", "nova-contracts") def test_literal_dest_name(self): assert mig.table_pair_for("nova-contracts") == ("acdl-contracts", "nova-contracts") def test_unknown_name_raises(self): with pytest.raises(ValueError, match="unknown table"): mig.table_pair_for("nope") def test_custom_pairs(self): pairs = [("old-x", "new-x")] assert mig.table_pair_for("old-x", pairs=pairs) == ("old-x", "new-x") class TestDefaultPairs: def test_default_pairs_cover_both_tables(self): sources = [s for s, _ in mig.DEFAULT_TABLE_PAIRS] dests = [d for _, d in mig.DEFAULT_TABLE_PAIRS] assert sources == ["acdl-contracts", "acdl-change-requests"] assert dests == ["nova-contracts", "nova-change-requests"] class TestArgparser: def test_dry_run_default(self): args = mig.build_parser().parse_args([]) assert args.apply is False assert args.region == "us-east-1" assert args.table is None def test_apply_flag(self): args = mig.build_parser().parse_args(["--apply"]) assert args.apply is True def test_table_filter(self): args = mig.build_parser().parse_args(["--table", "contracts"]) assert args.table == "contracts" def test_source_dest_override(self): args = mig.build_parser().parse_args(["--source", "old", "--dest", "new"]) assert args.source == "old" assert args.dest == "new" class TestRunDryRun: """The dry-run path exercises the table-pair resolution + describes both tables without writing. We stub the boto3 client so no AWS access occurs.""" def _fake_client(self, describable=True): client = type("FakeClient", (), {})() def describe_table(TableName): if not describable: raise Exception("ResourceNotFoundException") return {"Table": {"ItemCount": 0}} client.describe_table = describe_table client.scan = lambda **k: {"Items": []} client.put_item = lambda **k: None return client def test_run_dry_run_reports_planned_copy(self, monkeypatch, capsys): # Build args with both default pairs. args = mig.build_parser().parse_args([]) # Stub the client constructor so no real boto3 client is built. monkeypatch.setattr(mig.boto3, "client", lambda *a, **k: self._fake_client()) rc = mig.run(args) out = capsys.readouterr().out assert rc == 0 assert "DRY-RUN" in out assert "acdl-contracts" in out and "nova-contracts" in out assert "acdl-change-requests" in out and "nova-change-requests" in out assert "would PutItem" in out assert "NOT deleted" in out def test_run_source_table_not_describable_fails(self, monkeypatch, capsys): args = mig.build_parser().parse_args([]) # First describe_table (source) raises, second (dest) is fine — emulate by # raising on the first call only. calls = {"n": 0} client = type("FakeClient", (), {})() def describe_table(TableName): calls["n"] += 1 if calls["n"] % 2 == 1: # source (odd calls) raise Exception("ResourceNotFoundException") return {"Table": {"ItemCount": 0}} client.describe_table = describe_table client.scan = lambda **k: {"Items": []} client.put_item = lambda **k: None monkeypatch.setattr(mig.boto3, "client", lambda *a, **k: client) rc = mig.run(args) err = capsys.readouterr().err assert rc == 1 assert "not describable" in err