cumulusci-plus 5.0.21__py3-none-any.whl → 5.0.35__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/logger.py +2 -2
  3. cumulusci/cli/service.py +20 -0
  4. cumulusci/cli/task.py +17 -0
  5. cumulusci/cli/tests/test_error.py +3 -1
  6. cumulusci/cli/tests/test_flow.py +279 -2
  7. cumulusci/cli/tests/test_service.py +15 -12
  8. cumulusci/cli/tests/test_task.py +88 -2
  9. cumulusci/cli/tests/utils.py +1 -4
  10. cumulusci/core/config/base_task_flow_config.py +26 -1
  11. cumulusci/core/config/project_config.py +2 -20
  12. cumulusci/core/config/tests/test_config_expensive.py +9 -3
  13. cumulusci/core/config/universal_config.py +3 -4
  14. cumulusci/core/dependencies/base.py +1 -1
  15. cumulusci/core/dependencies/dependencies.py +1 -1
  16. cumulusci/core/dependencies/github.py +1 -2
  17. cumulusci/core/dependencies/resolvers.py +1 -1
  18. cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
  19. cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
  20. cumulusci/core/flowrunner.py +90 -6
  21. cumulusci/core/github.py +1 -1
  22. cumulusci/core/sfdx.py +3 -1
  23. cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
  24. cumulusci/core/source_transforms/transforms.py +1 -1
  25. cumulusci/core/tasks.py +13 -2
  26. cumulusci/core/tests/test_flowrunner.py +100 -0
  27. cumulusci/core/tests/test_tasks.py +65 -0
  28. cumulusci/core/utils.py +3 -1
  29. cumulusci/core/versions.py +1 -1
  30. cumulusci/cumulusci.yml +55 -0
  31. cumulusci/oauth/client.py +1 -1
  32. cumulusci/plugins/plugin_base.py +5 -3
  33. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  34. cumulusci/salesforce_api/rest_deploy.py +1 -1
  35. cumulusci/schema/cumulusci.jsonschema.json +64 -0
  36. cumulusci/tasks/apex/anon.py +1 -1
  37. cumulusci/tasks/apex/testrunner.py +416 -142
  38. cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
  39. cumulusci/tasks/bulkdata/extract.py +0 -1
  40. cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
  41. cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
  42. cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
  43. cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
  44. cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
  45. cumulusci/tasks/bulkdata/select_utils.py +1 -1
  46. cumulusci/tasks/bulkdata/snowfakery.py +100 -25
  47. cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
  48. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  49. cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
  50. cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
  51. cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
  52. cumulusci/tasks/create_package_version.py +190 -16
  53. cumulusci/tasks/datadictionary.py +1 -1
  54. cumulusci/tasks/metadata_etl/base.py +7 -3
  55. cumulusci/tasks/metadata_etl/layouts.py +1 -1
  56. cumulusci/tasks/metadata_etl/permissions.py +1 -1
  57. cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
  58. cumulusci/tasks/push/README.md +15 -17
  59. cumulusci/tasks/release_notes/README.md +13 -13
  60. cumulusci/tasks/release_notes/generator.py +13 -8
  61. cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
  62. cumulusci/tasks/salesforce/Deploy.py +53 -2
  63. cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
  64. cumulusci/tasks/salesforce/__init__.py +1 -0
  65. cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
  66. cumulusci/tasks/salesforce/composite.py +1 -1
  67. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  68. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  69. cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
  70. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  71. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  72. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  73. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  74. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  75. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  76. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
  77. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  78. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  79. cumulusci/tasks/salesforce/update_external_credential.py +562 -0
  80. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  81. cumulusci/tasks/salesforce/update_profile.py +17 -13
  82. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  83. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  84. cumulusci/tasks/sfdmu/__init__.py +0 -0
  85. cumulusci/tasks/sfdmu/sfdmu.py +363 -0
  86. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  87. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  88. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  89. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  90. cumulusci/tasks/tests/test_util.py +42 -0
  91. cumulusci/tasks/util.py +37 -1
  92. cumulusci/tasks/utility/copyContents.py +402 -0
  93. cumulusci/tasks/utility/credentialManager.py +256 -0
  94. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  95. cumulusci/tasks/utility/env_management.py +1 -1
  96. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  97. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  98. cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
  99. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  100. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
  101. cumulusci/tests/test_integration_infrastructure.py +3 -1
  102. cumulusci/tests/test_utils.py +70 -6
  103. cumulusci/utils/__init__.py +54 -9
  104. cumulusci/utils/classutils.py +5 -2
  105. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  106. cumulusci/utils/options.py +23 -1
  107. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  108. cumulusci/utils/yaml/cumulusci_yml.py +7 -3
  109. cumulusci/utils/yaml/model_parser.py +2 -2
  110. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  111. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  112. cumulusci/vcs/base.py +23 -15
  113. cumulusci/vcs/bootstrap.py +5 -4
  114. cumulusci/vcs/utils/list_modified_files.py +189 -0
  115. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  116. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
  117. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +121 -96
  118. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
  119. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
  120. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
  121. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
@@ -599,6 +599,7 @@ class TestMappingParser:
599
599
  mock.ANY, # This is a function def
600
600
  mock.ANY,
601
601
  DataOperationType.INSERT,
602
+ None, # validation_result
602
603
  )
603
604
 
604
605
  ms._validate_field_dict.assert_has_calls(
@@ -612,6 +613,7 @@ class TestMappingParser:
612
613
  mock.ANY, # local function def
613
614
  False,
614
615
  DataOperationType.INSERT,
616
+ None,
615
617
  ),
616
618
  mock.call(
617
619
  {"ns__Test__c": {"name": "ns__Test__c", "createable": True}},
@@ -620,6 +622,7 @@ class TestMappingParser:
620
622
  mock.ANY, # local function def
621
623
  False,
622
624
  DataOperationType.INSERT,
625
+ None,
623
626
  ),
624
627
  ]
625
628
  )
@@ -668,6 +671,7 @@ class TestMappingParser:
668
671
  mock.ANY, # local function def
669
672
  mock.ANY,
670
673
  DataOperationType.INSERT,
674
+ None,
671
675
  )
672
676
 
673
677
  ms._validate_field_dict.assert_has_calls(
@@ -686,6 +690,7 @@ class TestMappingParser:
686
690
  mock.ANY, # local function def.
687
691
  False,
688
692
  DataOperationType.INSERT,
693
+ None,
689
694
  ),
690
695
  mock.call(
691
696
  {
@@ -701,6 +706,7 @@ class TestMappingParser:
701
706
  mock.ANY, # local function def.
702
707
  False,
703
708
  DataOperationType.INSERT,
709
+ None,
704
710
  ),
705
711
  ]
706
712
  )
@@ -734,6 +740,7 @@ class TestMappingParser:
734
740
  None,
735
741
  None,
736
742
  DataOperationType.INSERT,
743
+ None,
737
744
  )
738
745
 
739
746
  ms._validate_field_dict.assert_has_calls(
@@ -745,6 +752,7 @@ class TestMappingParser:
745
752
  None,
746
753
  False,
747
754
  DataOperationType.INSERT,
755
+ None,
748
756
  ),
749
757
  mock.call(
750
758
  {"Field__c": {"name": "Field__c", "createable": True}},
@@ -753,6 +761,7 @@ class TestMappingParser:
753
761
  None,
754
762
  False,
755
763
  DataOperationType.INSERT,
764
+ None,
756
765
  ),
757
766
  ]
758
767
  )
@@ -788,6 +797,7 @@ class TestMappingParser:
788
797
  None,
789
798
  None,
790
799
  DataOperationType.INSERT,
800
+ None,
791
801
  )
792
802
 
793
803
  ms._validate_field_dict.assert_not_called()
@@ -823,6 +833,7 @@ class TestMappingParser:
823
833
  None,
824
834
  None,
825
835
  DataOperationType.INSERT,
836
+ None,
826
837
  )
827
838
 
828
839
  ms._validate_field_dict.assert_has_calls(
@@ -834,6 +845,7 @@ class TestMappingParser:
834
845
  None,
835
846
  False,
836
847
  DataOperationType.INSERT,
848
+ None,
837
849
  )
838
850
  ]
839
851
  )
@@ -881,6 +893,7 @@ class TestMappingParser:
881
893
  None,
882
894
  None,
883
895
  DataOperationType.INSERT,
896
+ None,
884
897
  )
885
898
 
886
899
  ms._validate_field_dict.assert_has_calls(
@@ -899,6 +912,7 @@ class TestMappingParser:
899
912
  None,
900
913
  False,
901
914
  DataOperationType.INSERT,
915
+ None,
902
916
  ),
903
917
  mock.call(
904
918
  {
@@ -914,6 +928,7 @@ class TestMappingParser:
914
928
  None,
915
929
  False,
916
930
  DataOperationType.INSERT,
931
+ None,
917
932
  ),
918
933
  ]
919
934
  )
@@ -962,6 +977,7 @@ class TestMappingParser:
962
977
  None,
963
978
  None,
964
979
  DataOperationType.INSERT,
980
+ None,
965
981
  )
966
982
 
967
983
  ms._validate_field_dict.assert_has_calls(
@@ -980,6 +996,7 @@ class TestMappingParser:
980
996
  None,
981
997
  False,
982
998
  DataOperationType.INSERT,
999
+ None,
983
1000
  ),
984
1001
  mock.call(
985
1002
  {
@@ -995,6 +1012,7 @@ class TestMappingParser:
995
1012
  None,
996
1013
  False,
997
1014
  DataOperationType.INSERT,
1015
+ None,
998
1016
  ),
999
1017
  ]
1000
1018
  )
@@ -1185,15 +1203,58 @@ class TestMappingParser:
1185
1203
  {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1186
1204
  )
1187
1205
 
1206
+ # Should raise BulkDataException when drop_missing=False
1207
+ with pytest.raises(
1208
+ BulkDataException,
1209
+ match="One or more schema or permissions errors blocked the operation",
1210
+ ):
1211
+ validate_and_inject_mapping(
1212
+ mapping=mapping,
1213
+ sf=org_config.salesforce_client,
1214
+ namespace="",
1215
+ data_operation=DataOperationType.INSERT,
1216
+ inject_namespaces=False,
1217
+ drop_missing=False,
1218
+ )
1219
+
1220
+ # Verify the error was logged
1221
+ expected_error_message = (
1222
+ "One or more required fields are missing for loading on Account :{'Name'}"
1223
+ )
1224
+ error_logs = [
1225
+ record.message for record in caplog.records if record.levelname == "ERROR"
1226
+ ]
1227
+ assert any(expected_error_message in error_log for error_log in error_logs)
1228
+
1229
+ @responses.activate
1230
+ def test_validate_and_inject_mapping_allows_missing_required_fields_with_drop_missing(
1231
+ self, caplog
1232
+ ):
1233
+ """Test that drop_missing=True allows missing required fields (with warning)."""
1234
+ caplog.set_level(logging.ERROR)
1235
+ mock_describe_calls()
1236
+ mapping = parse_from_yaml(
1237
+ StringIO(
1238
+ (
1239
+ "Insert Accounts:\n sf_object: Account\n table: Account\n fields:\n - ns__Description__c\n"
1240
+ )
1241
+ )
1242
+ )
1243
+ org_config = DummyOrgConfig(
1244
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1245
+ )
1246
+
1247
+ # Should NOT raise exception when drop_missing=True, even with missing required fields
1188
1248
  validate_and_inject_mapping(
1189
1249
  mapping=mapping,
1190
1250
  sf=org_config.salesforce_client,
1191
1251
  namespace="",
1192
1252
  data_operation=DataOperationType.INSERT,
1193
1253
  inject_namespaces=False,
1194
- drop_missing=False,
1254
+ drop_missing=True,
1195
1255
  )
1196
1256
 
1257
+ # Verify the error was still logged as a warning
1197
1258
  expected_error_message = (
1198
1259
  "One or more required fields are missing for loading on Account :{'Name'}"
1199
1260
  )
@@ -1656,3 +1717,704 @@ class TestUpsertKeyValidations:
1656
1717
  record.message for record in caplog.records if record.levelname == "ERROR"
1657
1718
  ]
1658
1719
  assert any(expected_error_message in error_log for error_log in error_logs)
1720
+
1721
+
1722
+ class TestValidationResult:
1723
+ """Tests for ValidationResult class"""
1724
+
1725
+ def test_validation_result_initialization(self):
1726
+ """Test ValidationResult initializes with empty lists"""
1727
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1728
+
1729
+ result = ValidationResult()
1730
+ assert result.errors == []
1731
+ assert result.warnings == []
1732
+ assert not result.has_errors()
1733
+
1734
+ def test_validation_result_add_error(self, caplog):
1735
+ """Test adding errors to ValidationResult"""
1736
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1737
+
1738
+ caplog.set_level(logging.ERROR)
1739
+ result = ValidationResult()
1740
+
1741
+ result.add_error("Test error 1")
1742
+ result.add_error("Test error 2")
1743
+
1744
+ assert len(result.errors) == 2
1745
+ assert "Test error 1" in result.errors
1746
+ assert "Test error 2" in result.errors
1747
+ assert result.has_errors()
1748
+
1749
+ # Verify errors are also logged
1750
+ assert "Test error 1" in caplog.text
1751
+ assert "Test error 2" in caplog.text
1752
+
1753
+ def test_validation_result_add_warning(self, caplog):
1754
+ """Test adding warnings to ValidationResult"""
1755
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1756
+
1757
+ caplog.set_level(logging.WARNING)
1758
+ result = ValidationResult()
1759
+
1760
+ result.add_warning("Test warning 1")
1761
+ result.add_warning("Test warning 2")
1762
+
1763
+ assert len(result.warnings) == 2
1764
+ assert "Test warning 1" in result.warnings
1765
+ assert "Test warning 2" in result.warnings
1766
+ assert not result.has_errors() # Warnings don't count as errors
1767
+
1768
+ # Verify warnings are also logged
1769
+ assert "Test warning 1" in caplog.text
1770
+ assert "Test warning 2" in caplog.text
1771
+
1772
+ def test_validation_result_mixed_errors_and_warnings(self):
1773
+ """Test ValidationResult with both errors and warnings"""
1774
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1775
+
1776
+ result = ValidationResult()
1777
+
1778
+ result.add_warning("Warning message")
1779
+ result.add_error("Error message")
1780
+ result.add_warning("Another warning")
1781
+
1782
+ assert len(result.errors) == 1
1783
+ assert len(result.warnings) == 2
1784
+ assert result.has_errors()
1785
+
1786
+
1787
+ class TestValidateOnlyMode:
1788
+ """Tests for validate_only mode in validate_and_inject_mapping"""
1789
+
1790
+ @responses.activate
1791
+ def test_validate_only_returns_validation_result(self):
1792
+ """Test that validate_only=True returns ValidationResult"""
1793
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1794
+
1795
+ mock_describe_calls()
1796
+ mapping = parse_from_yaml(
1797
+ StringIO(
1798
+ "Insert Accounts:\n sf_object: Account\n table: Account\n fields:\n - Name"
1799
+ )
1800
+ )
1801
+ org_config = DummyOrgConfig(
1802
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1803
+ )
1804
+
1805
+ result = validate_and_inject_mapping(
1806
+ mapping=mapping,
1807
+ sf=org_config.salesforce_client,
1808
+ namespace=None,
1809
+ data_operation=DataOperationType.INSERT,
1810
+ inject_namespaces=False,
1811
+ drop_missing=False,
1812
+ validate_only=True,
1813
+ )
1814
+
1815
+ assert result is not None
1816
+ assert isinstance(result, ValidationResult)
1817
+ assert not result.has_errors()
1818
+
1819
+ @responses.activate
1820
+ def test_validate_only_false_returns_none(self):
1821
+ """Test that validate_only=False returns None"""
1822
+ mock_describe_calls()
1823
+ mapping = parse_from_yaml(
1824
+ StringIO(
1825
+ "Insert Accounts:\n sf_object: Account\n table: Account\n fields:\n - Name"
1826
+ )
1827
+ )
1828
+ org_config = DummyOrgConfig(
1829
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1830
+ )
1831
+
1832
+ result = validate_and_inject_mapping(
1833
+ mapping=mapping,
1834
+ sf=org_config.salesforce_client,
1835
+ namespace=None,
1836
+ data_operation=DataOperationType.INSERT,
1837
+ inject_namespaces=False,
1838
+ drop_missing=False,
1839
+ validate_only=False,
1840
+ )
1841
+
1842
+ assert result is None
1843
+
1844
+ @responses.activate
1845
+ def test_validate_only_collects_missing_field_errors(self):
1846
+ """Test that validate_only collects missing field errors"""
1847
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1848
+
1849
+ mock_describe_calls()
1850
+ mapping = parse_from_yaml(
1851
+ StringIO(
1852
+ "Insert Accounts:\n sf_object: Account\n table: Account\n fields:\n - Nonsense__c"
1853
+ )
1854
+ )
1855
+ org_config = DummyOrgConfig(
1856
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1857
+ )
1858
+
1859
+ result = validate_and_inject_mapping(
1860
+ mapping=mapping,
1861
+ sf=org_config.salesforce_client,
1862
+ namespace=None,
1863
+ data_operation=DataOperationType.INSERT,
1864
+ inject_namespaces=False,
1865
+ drop_missing=False,
1866
+ validate_only=True,
1867
+ )
1868
+
1869
+ assert result is not None
1870
+ assert isinstance(result, ValidationResult)
1871
+ # Should have warnings about missing field
1872
+ assert any("Nonsense__c" in warning for warning in result.warnings)
1873
+
1874
+ @responses.activate
1875
+ def test_validate_only_collects_missing_required_field_errors(self):
1876
+ """Test that validate_only collects missing required field errors"""
1877
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1878
+
1879
+ mock_describe_calls()
1880
+ mapping = parse_from_yaml(
1881
+ StringIO(
1882
+ "Insert Accounts:\n sf_object: Account\n table: Account\n fields:\n - Description"
1883
+ )
1884
+ )
1885
+ org_config = DummyOrgConfig(
1886
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1887
+ )
1888
+
1889
+ result = validate_and_inject_mapping(
1890
+ mapping=mapping,
1891
+ sf=org_config.salesforce_client,
1892
+ namespace=None,
1893
+ data_operation=DataOperationType.INSERT,
1894
+ inject_namespaces=False,
1895
+ drop_missing=False,
1896
+ validate_only=True,
1897
+ )
1898
+
1899
+ assert result is not None
1900
+ assert isinstance(result, ValidationResult)
1901
+ assert result.has_errors()
1902
+ # Should have error about missing required field 'Name'
1903
+ assert any("required fields" in error.lower() for error in result.errors)
1904
+ assert any("Name" in error for error in result.errors)
1905
+
1906
+ @responses.activate
1907
+ def test_validate_only_early_return_on_sobject_error(self):
1908
+ """Test that validate_only returns early when sObject doesn't exist"""
1909
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1910
+
1911
+ mock_describe_calls()
1912
+ mapping = parse_from_yaml(
1913
+ StringIO(
1914
+ "Insert Invalid:\n sf_object: InvalidObject__c\n table: InvalidObject\n fields:\n - Name"
1915
+ )
1916
+ )
1917
+ org_config = DummyOrgConfig(
1918
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1919
+ )
1920
+
1921
+ result = validate_and_inject_mapping(
1922
+ mapping=mapping,
1923
+ sf=org_config.salesforce_client,
1924
+ namespace=None,
1925
+ data_operation=DataOperationType.INSERT,
1926
+ inject_namespaces=False,
1927
+ drop_missing=False,
1928
+ validate_only=True,
1929
+ )
1930
+
1931
+ assert result is not None
1932
+ assert isinstance(result, ValidationResult)
1933
+ # Should have warning about missing object
1934
+ assert any("InvalidObject__c" in warning for warning in result.warnings)
1935
+
1936
+ @responses.activate
1937
+ def test_validate_only_collects_lookup_errors(self):
1938
+ """Test that validate_only collects lookup validation errors"""
1939
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1940
+
1941
+ mock_describe_calls()
1942
+ mapping = parse_from_yaml(
1943
+ StringIO(
1944
+ (
1945
+ "Insert Contacts:\n sf_object: Contact\n table: Contact\n fields:\n - LastName\n lookups:\n AccountId:\n table: Account"
1946
+ )
1947
+ )
1948
+ )
1949
+ org_config = DummyOrgConfig(
1950
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1951
+ )
1952
+
1953
+ result = validate_and_inject_mapping(
1954
+ mapping=mapping,
1955
+ sf=org_config.salesforce_client,
1956
+ namespace=None,
1957
+ data_operation=DataOperationType.INSERT,
1958
+ inject_namespaces=False,
1959
+ drop_missing=False,
1960
+ validate_only=True,
1961
+ )
1962
+
1963
+ assert result is not None
1964
+ assert isinstance(result, ValidationResult)
1965
+ assert result.has_errors()
1966
+ # Should have error about missing Account table
1967
+ assert any(
1968
+ "Account" in error and "does not exist" in error for error in result.errors
1969
+ )
1970
+
1971
+ @responses.activate
1972
+ def test_validate_only_without_load_skips_lookup_validation(self):
1973
+ """Test that validate_only skips lookup validation for QUERY operations"""
1974
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
1975
+
1976
+ mock_describe_calls()
1977
+ mapping = parse_from_yaml(
1978
+ StringIO(
1979
+ (
1980
+ "Insert Contacts:\n sf_object: Contact\n table: Contact\n fields:\n - LastName\n lookups:\n AccountId:\n table: Account"
1981
+ )
1982
+ )
1983
+ )
1984
+ org_config = DummyOrgConfig(
1985
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
1986
+ )
1987
+
1988
+ result = validate_and_inject_mapping(
1989
+ mapping=mapping,
1990
+ sf=org_config.salesforce_client,
1991
+ namespace=None,
1992
+ data_operation=DataOperationType.QUERY, # Not a load operation
1993
+ inject_namespaces=False,
1994
+ drop_missing=False,
1995
+ validate_only=True,
1996
+ )
1997
+
1998
+ assert result is not None
1999
+ assert isinstance(result, ValidationResult)
2000
+ # Should not have lookup validation errors since it's a QUERY
2001
+ assert not any(
2002
+ "Account" in error and "does not exist" in error for error in result.errors
2003
+ )
2004
+
2005
+
2006
+ class TestValidationResultParameter:
2007
+ """Tests for optional ValidationResult parameter in validation methods"""
2008
+
2009
+ def test_check_required_with_validation_result(self):
2010
+ """Test check_required adds errors to ValidationResult when provided"""
2011
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2012
+
2013
+ ms = MappingStep(
2014
+ sf_object="Account",
2015
+ fields=["Description"],
2016
+ action=DataOperationType.INSERT,
2017
+ )
2018
+ fields_describe = CaseInsensitiveDict(
2019
+ {
2020
+ "Name": {
2021
+ "createable": True,
2022
+ "nillable": False,
2023
+ "defaultedOnCreate": False,
2024
+ "defaultValue": None,
2025
+ },
2026
+ "Description": {
2027
+ "createable": True,
2028
+ "nillable": True,
2029
+ "defaultedOnCreate": False,
2030
+ "defaultValue": None,
2031
+ },
2032
+ }
2033
+ )
2034
+
2035
+ validation_result = ValidationResult()
2036
+ result = ms.check_required(fields_describe, validation_result)
2037
+
2038
+ assert not result # Should return False due to missing required field
2039
+ assert validation_result.has_errors()
2040
+ assert any(
2041
+ "required fields" in error.lower() for error in validation_result.errors
2042
+ )
2043
+ assert any("Name" in error for error in validation_result.errors)
2044
+
2045
+ def test_check_required_without_validation_result_logs(self, caplog):
2046
+ """Test check_required logs errors when ValidationResult not provided"""
2047
+ caplog.set_level(logging.ERROR)
2048
+ ms = MappingStep(
2049
+ sf_object="Account",
2050
+ fields=["Description"],
2051
+ action=DataOperationType.INSERT,
2052
+ )
2053
+ fields_describe = CaseInsensitiveDict(
2054
+ {
2055
+ "Name": {
2056
+ "createable": True,
2057
+ "nillable": False,
2058
+ "defaultedOnCreate": False,
2059
+ "defaultValue": None,
2060
+ },
2061
+ }
2062
+ )
2063
+
2064
+ result = ms.check_required(fields_describe, None)
2065
+
2066
+ assert not result
2067
+ assert "required fields" in caplog.text.lower()
2068
+ assert "Name" in caplog.text
2069
+
2070
+ def test_validate_sobject_with_validation_result(self):
2071
+ """Test _validate_sobject adds warnings to ValidationResult"""
2072
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2073
+
2074
+ ms = MappingStep(
2075
+ sf_object="InvalidObject__c",
2076
+ fields=["Name"],
2077
+ action=DataOperationType.INSERT,
2078
+ )
2079
+
2080
+ validation_result = ValidationResult()
2081
+ result = ms._validate_sobject(
2082
+ CaseInsensitiveDict({"Account": {"createable": True}}),
2083
+ None,
2084
+ None,
2085
+ DataOperationType.INSERT,
2086
+ validation_result,
2087
+ )
2088
+
2089
+ assert not result
2090
+ assert len(validation_result.warnings) > 0
2091
+ assert any(
2092
+ "InvalidObject__c" in warning for warning in validation_result.warnings
2093
+ )
2094
+
2095
+ def test_validate_field_dict_with_validation_result(self):
2096
+ """Test _validate_field_dict adds warnings to ValidationResult"""
2097
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2098
+
2099
+ ms = MappingStep(
2100
+ sf_object="Account",
2101
+ fields=["Name", "NonexistentField__c"],
2102
+ action=DataOperationType.INSERT,
2103
+ )
2104
+
2105
+ validation_result = ValidationResult()
2106
+ result = ms._validate_field_dict(
2107
+ describe=CaseInsensitiveDict({"Name": {"createable": True}}),
2108
+ field_dict=ms.fields_,
2109
+ inject=None,
2110
+ strip=None,
2111
+ drop_missing=False,
2112
+ data_operation_type=DataOperationType.INSERT,
2113
+ validation_result=validation_result,
2114
+ )
2115
+
2116
+ assert not result
2117
+ assert len(validation_result.warnings) > 0
2118
+ assert any(
2119
+ "NonexistentField__c" in warning for warning in validation_result.warnings
2120
+ )
2121
+
2122
+ def test_infer_and_validate_lookups_with_validation_result(self):
2123
+ """Test _infer_and_validate_lookups adds errors to ValidationResult"""
2124
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2125
+
2126
+ mock_sf = mock.Mock()
2127
+ mock_sf.Contact.describe.return_value = {
2128
+ "fields": [
2129
+ {
2130
+ "name": "AccountId",
2131
+ "referenceTo": ["Account"],
2132
+ }
2133
+ ]
2134
+ }
2135
+
2136
+ mapping = {
2137
+ "Insert Contacts": MappingStep(
2138
+ sf_object="Contact",
2139
+ table="Contact",
2140
+ fields=["LastName"],
2141
+ lookups={"AccountId": MappingLookup(table="Account", name="AccountId")},
2142
+ )
2143
+ }
2144
+
2145
+ validation_result = ValidationResult()
2146
+ _infer_and_validate_lookups(mapping, mock_sf, validation_result)
2147
+
2148
+ # Should have error about missing Account table
2149
+ assert validation_result.has_errors()
2150
+ assert any(
2151
+ "Account" in error and "does not exist" in error
2152
+ for error in validation_result.errors
2153
+ )
2154
+
2155
+ def test_infer_and_validate_lookups_without_validation_result_raises(self):
2156
+ """Test _infer_and_validate_lookups raises exception when ValidationResult not provided"""
2157
+ mock_sf = mock.Mock()
2158
+ mock_sf.Contact.describe.return_value = {
2159
+ "fields": [
2160
+ {
2161
+ "name": "AccountId",
2162
+ "referenceTo": ["Account"],
2163
+ }
2164
+ ]
2165
+ }
2166
+
2167
+ mapping = {
2168
+ "Insert Contacts": MappingStep(
2169
+ sf_object="Contact",
2170
+ table="Contact",
2171
+ fields=["LastName"],
2172
+ lookups={"AccountId": MappingLookup(table="Account", name="AccountId")},
2173
+ )
2174
+ }
2175
+
2176
+ with pytest.raises(BulkDataException) as e:
2177
+ _infer_and_validate_lookups(mapping, mock_sf, None)
2178
+
2179
+ assert "relationship errors" in str(e.value).lower()
2180
+
2181
+
2182
+ class TestValidationResultCoverage:
2183
+ """Additional tests to achieve full coverage of ValidationResult code paths"""
2184
+
2185
+ def test_validate_field_dict_duplicate_field_with_validation_result(self):
2186
+ """Test _validate_field_dict with duplicate fields (injected and original) using ValidationResult"""
2187
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2188
+
2189
+ ms = MappingStep(
2190
+ sf_object="Account",
2191
+ fields=["Test__c"],
2192
+ action=DataOperationType.INSERT,
2193
+ )
2194
+
2195
+ validation_result = ValidationResult()
2196
+ # Both Test__c and ns__Test__c exist in describe
2197
+ result = ms._validate_field_dict(
2198
+ describe=CaseInsensitiveDict(
2199
+ {"Test__c": {"createable": True}, "ns__Test__c": {"createable": True}}
2200
+ ),
2201
+ field_dict=ms.fields_,
2202
+ inject=lambda field: f"ns__{field}",
2203
+ strip=None,
2204
+ drop_missing=False,
2205
+ data_operation_type=DataOperationType.INSERT,
2206
+ validation_result=validation_result,
2207
+ )
2208
+
2209
+ assert result
2210
+ # Should have warning about both fields being present
2211
+ assert any(
2212
+ "Both" in warning and "Test__c" in warning
2213
+ for warning in validation_result.warnings
2214
+ )
2215
+
2216
+ def test_validate_field_dict_permission_error_with_validation_result(self):
2217
+ """Test _validate_field_dict with field permission errors using ValidationResult"""
2218
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2219
+
2220
+ ms = MappingStep(
2221
+ sf_object="Account",
2222
+ fields=["Name"],
2223
+ action=DataOperationType.INSERT,
2224
+ )
2225
+
2226
+ validation_result = ValidationResult()
2227
+ result = ms._validate_field_dict(
2228
+ describe=CaseInsensitiveDict({"Name": {"createable": False}}),
2229
+ field_dict=ms.fields_,
2230
+ inject=None,
2231
+ strip=None,
2232
+ drop_missing=False,
2233
+ data_operation_type=DataOperationType.INSERT,
2234
+ validation_result=validation_result,
2235
+ )
2236
+
2237
+ assert not result
2238
+ # Should have warning about incorrect permissions
2239
+ assert any(
2240
+ "does not have the correct permissions" in warning
2241
+ for warning in validation_result.warnings
2242
+ )
2243
+
2244
+ def test_validate_sobject_permission_error_with_validation_result(self):
2245
+ """Test _validate_sobject with permission errors using ValidationResult"""
2246
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2247
+
2248
+ ms = MappingStep(
2249
+ sf_object="Account",
2250
+ fields=["Name"],
2251
+ action=DataOperationType.INSERT,
2252
+ )
2253
+
2254
+ validation_result = ValidationResult()
2255
+ result = ms._validate_sobject(
2256
+ CaseInsensitiveDict({"Account": {"createable": False}}),
2257
+ None,
2258
+ None,
2259
+ DataOperationType.INSERT,
2260
+ validation_result,
2261
+ )
2262
+
2263
+ assert not result
2264
+ # Should have warning about incorrect permissions
2265
+ assert any(
2266
+ "does not have the correct permissions" in warning
2267
+ for warning in validation_result.warnings
2268
+ )
2269
+
2270
+ def test_infer_and_validate_lookups_invalid_reference_with_validation_result(self):
2271
+ """Test _infer_and_validate_lookups with invalid reference using ValidationResult"""
2272
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2273
+
2274
+ mock_sf = mock.Mock()
2275
+ # Mock Event.describe
2276
+ mock_sf.Event.describe.return_value = {
2277
+ "fields": [
2278
+ {
2279
+ "name": "Description",
2280
+ "referenceTo": [],
2281
+ }
2282
+ ]
2283
+ }
2284
+ # Mock Contact.describe
2285
+ mock_sf.Contact.describe.return_value = {
2286
+ "fields": [
2287
+ {
2288
+ "name": "AccountId",
2289
+ "referenceTo": ["Account"], # Only Account is valid
2290
+ }
2291
+ ]
2292
+ }
2293
+
2294
+ mapping = {
2295
+ "Insert Events": MappingStep(
2296
+ sf_object="Event",
2297
+ table="Event",
2298
+ fields=["Description"],
2299
+ ),
2300
+ "Insert Contacts": MappingStep(
2301
+ sf_object="Contact",
2302
+ table="Contact",
2303
+ fields=["LastName"],
2304
+ lookups={
2305
+ "AccountId": MappingLookup(table="Event", name="AccountId")
2306
+ }, # Invalid - Event is not a valid lookup
2307
+ ),
2308
+ }
2309
+
2310
+ validation_result = ValidationResult()
2311
+ _infer_and_validate_lookups(mapping, mock_sf, validation_result)
2312
+
2313
+ # Should have error about invalid lookup
2314
+ assert validation_result.has_errors()
2315
+ assert any(
2316
+ "is not a valid lookup" in error for error in validation_result.errors
2317
+ )
2318
+
2319
+ def test_infer_and_validate_lookups_polymorphic_incorrect_order_with_validation_result(
2320
+ self,
2321
+ ):
2322
+ """Test _infer_and_validate_lookups with polymorphic lookups in incorrect order using ValidationResult"""
2323
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2324
+
2325
+ mock_sf = mock.Mock()
2326
+ # Mock Account.describe
2327
+ mock_sf.Account.describe.return_value = {
2328
+ "fields": [
2329
+ {
2330
+ "name": "Name",
2331
+ "referenceTo": [],
2332
+ }
2333
+ ]
2334
+ }
2335
+ # Mock Event.describe
2336
+ mock_sf.Event.describe.return_value = {
2337
+ "fields": [
2338
+ {
2339
+ "name": "WhatId",
2340
+ "referenceTo": ["Account", "Opportunity"],
2341
+ }
2342
+ ]
2343
+ }
2344
+ # Mock Opportunity.describe
2345
+ mock_sf.Opportunity.describe.return_value = {
2346
+ "fields": [
2347
+ {
2348
+ "name": "Name",
2349
+ "referenceTo": [],
2350
+ }
2351
+ ]
2352
+ }
2353
+
2354
+ # Event comes before Opportunity, but WhatId references both Account and Opportunity
2355
+ mapping = {
2356
+ "Insert Account": MappingStep(
2357
+ sf_object="Account",
2358
+ table="Account",
2359
+ fields=["Name"],
2360
+ ),
2361
+ "Insert Events": MappingStep(
2362
+ sf_object="Event",
2363
+ table="Event",
2364
+ fields=["Description"],
2365
+ lookups={
2366
+ "WhatId": MappingLookup(
2367
+ table=["Account", "Opportunity"], name="WhatId"
2368
+ )
2369
+ },
2370
+ ),
2371
+ "Insert Opportunity": MappingStep(
2372
+ sf_object="Opportunity",
2373
+ table="Opportunity",
2374
+ fields=["Name"],
2375
+ ),
2376
+ }
2377
+
2378
+ validation_result = ValidationResult()
2379
+ _infer_and_validate_lookups(mapping, mock_sf, validation_result)
2380
+
2381
+ # Should have error about incorrect order
2382
+ assert validation_result.has_errors()
2383
+ assert any("must precede" in error for error in validation_result.errors)
2384
+
2385
+ @responses.activate
2386
+ def test_validate_and_inject_mapping_required_lookup_dropped_with_validate_only(
2387
+ self,
2388
+ ):
2389
+ """Test validate_and_inject_mapping when a required lookup is dropped in validate_only mode"""
2390
+ from cumulusci.tasks.bulkdata.mapping_parser import ValidationResult
2391
+
2392
+ mock_describe_calls()
2393
+ # Using Id field as a required lookup (it's non-nillable)
2394
+ mapping = parse_from_yaml(
2395
+ StringIO(
2396
+ (
2397
+ "Insert Accounts:\n sf_object: NotAccount\n table: Account\n fields:\n - Nonsense__c\n"
2398
+ "Insert Contacts:\n sf_object: Contact\n table: Contact\n fields:\n - LastName\n lookups:\n Id:\n table: Account"
2399
+ )
2400
+ )
2401
+ )
2402
+ org_config = DummyOrgConfig(
2403
+ {"instance_url": "https://example.com", "access_token": "abc123"}, "test"
2404
+ )
2405
+
2406
+ result = validate_and_inject_mapping(
2407
+ mapping=mapping,
2408
+ sf=org_config.salesforce_client,
2409
+ namespace=None,
2410
+ data_operation=DataOperationType.INSERT,
2411
+ inject_namespaces=False,
2412
+ drop_missing=True,
2413
+ validate_only=True,
2414
+ )
2415
+
2416
+ assert result is not None
2417
+ assert isinstance(result, ValidationResult)
2418
+ assert result.has_errors()
2419
+ # Should have error about required field being dropped
2420
+ assert any("is a required field" in error for error in result.errors)