"""REQ-288: tests for core/env_transition.py — detect_prior_env + record_applied_env. Uses moto (already a test dependency) to mock DynamoDB, mirroring the pattern in tests/test_contract_ingestor.py. The nova-contracts table is created with PK consumerRepo + SK contractId#submittedAt. """ import sys from pathlib import Path from unittest import mock import pytest ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from core import env_transition @pytest.fixture def moto_contracts_table(monkeypatch): """Spin up a moto-backed DynamoDB nova-contracts table.""" from moto import mock_aws import boto3 monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") with mock_aws(): dyn = boto3.client("dynamodb", region_name="us-east-1") dyn.create_table( TableName="nova-contracts", KeySchema=[ {"AttributeName": "consumerRepo", "KeyType": "HASH"}, {"AttributeName": "contractId#submittedAt", "KeyType": "RANGE"}, ], AttributeDefinitions=[ {"AttributeName": "consumerRepo", "AttributeType": "S"}, {"AttributeName": "contractId#submittedAt", "AttributeType": "S"}, ], BillingMode="PAY_PER_REQUEST", ) yield dyn class TestDetectPriorEnv: def test_returns_none_when_no_record_exists(self, moto_contracts_table): """First deploy: no prior record → None (no destroy needed).""" result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "dev") assert result is None def test_returns_prior_env_when_record_differs(self, moto_contracts_table): """Env change detected: last-applied was dev, new is qa → return 'dev'.""" env_transition.record_applied_env("assets", "acdl/consumer-a", "dev") result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "qa") assert result == "dev" def test_returns_none_when_record_matches_new_env(self, moto_contracts_table): """Re-apply same env: last-applied was dev, new is dev → None.""" env_transition.record_applied_env("assets", "acdl/consumer-a", "dev") result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "dev") assert result is None def test_returns_none_on_dynamodb_unreachable(self, monkeypatch): """DynamoDB unreachable (local/CI) → log warning + return None (conservative).""" def _raise(*args, **kwargs): raise RuntimeError("simulated DynamoDB unreachable") monkeypatch.setattr(env_transition, "_get_table", _raise) result = env_transition.detect_prior_env("assets", "acdl/consumer-a", "qa") assert result is None def test_scoped_to_consumer_repo(self, moto_contracts_table): """A different consumer's record does not affect this consumer's detect.""" env_transition.record_applied_env("assets", "acdl/consumer-a", "dev") result = env_transition.detect_prior_env("assets", "acdl/consumer-b", "qa") assert result is None class TestRecordAppliedEnv: def test_writes_record_to_table(self, moto_contracts_table): """record_applied_env writes an item with the right PK/SK + environment.""" ok = env_transition.record_applied_env("assets", "acdl/consumer-a", "dev") assert ok is True # Verify the item was written import boto3 resp = boto3.client("dynamodb", region_name="us-east-1").query( TableName="nova-contracts", KeyConditionExpression="consumerRepo = :repo", ExpressionAttributeValues={":repo": {"S": "acdl/consumer-a"}}, ) assert len(resp["Items"]) == 1 item = resp["Items"][0] assert item["consumerRepo"]["S"] == "acdl/consumer-a" assert item["environment"]["S"] == "dev" assert item["status"]["S"] == "applied" assert "#LAST_APPLIED#" in item["contractId#submittedAt"]["S"] def test_returns_false_on_dynamodb_unreachable(self, monkeypatch): """DynamoDB unreachable → return False (non-fatal, pipeline continues).""" def _raise(*args, **kwargs): raise RuntimeError("simulated DynamoDB unreachable") monkeypatch.setattr(env_transition, "_get_table", _raise) ok = env_transition.record_applied_env("assets", "acdl/consumer-a", "dev") assert ok is False def test_idempotent_multiple_writes(self, moto_contracts_table): """Multiple record calls with different envs write separate items (timestamped SKs). Same-second same-env writes collapse (put_item overwrites same PK+SK — the latest record wins, which is correct).""" env_transition.record_applied_env("assets", "acdl/consumer-a", "dev") env_transition.record_applied_env("assets", "acdl/consumer-a", "qa") import boto3 resp = boto3.client("dynamodb", region_name="us-east-1").query( TableName="nova-contracts", KeyConditionExpression="consumerRepo = :repo", ExpressionAttributeValues={":repo": {"S": "acdl/consumer-a"}}, ) # At least 1 item (same-second writes may collapse to 1; the latest env wins) assert len(resp["Items"]) >= 1 # The latest record should have the most recent env written envs = [item["environment"]["S"] for item in resp["Items"]] assert "qa" in envs or "dev" in envs class TestEnvTransitionCli: def test_detect_cli_returns_none_as_json(self, moto_contracts_table, capsys): """CLI detect command outputs JSON with prior_env: null.""" import json from core.env_transition import main rc = main(["prog", "detect", "--contract-id", "assets", "--consumer-repo", "acdl/c", "--new-env", "dev"]) assert rc == 0 out = json.loads(capsys.readouterr().out) assert out["prior_env"] is None def test_record_cli_outputs_json(self, moto_contracts_table, capsys): """CLI record command outputs JSON with recorded: true.""" import json from core.env_transition import main rc = main(["prog", "record", "--contract-id", "assets", "--consumer-repo", "acdl/c", "--env", "dev"]) assert rc == 0 out = json.loads(capsys.readouterr().out) assert out["recorded"] is True