Files
acdl/tests/test_migrate_dynamodb_data.py
T
Jon Chery 0e6ecae26d feat(P4): Nova rebrand — AWS resource migration (REQ-163)
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---
2026-07-30 01:54:26 +00:00

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