0e6ecae26d
Rename all acdl-* AWS resources → nova-* across terraform (DynamoDB, Secrets Manager, Lambda, SNS, SG, KMS alias, ECS, ECR, IAM user/policy, state bucket, ALB, VPC/subnet names). Lambda default table names → nova-* (D-111). State bucket backend → nova-tfstate (-migrate-state documented). New docs/NOVA_AWS_MIGRATION.md runbook (staged migration + rollback). New scripts/migrate_dynamodb_data.py (scan+copy, dry-run default). acdl-deploy- → nova-deploy- role ARN in deploy workflows. Test fixtures updated; terraform validate + pytest + run_ci.sh PASS. ---ci--- project: acdl phase: 4 milestone: v1.15 status: execute ---/ci---
148 lines
5.4 KiB
Python
148 lines
5.4 KiB
Python
"""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 |