|
16 | 16 | _RecordGet, |
17 | 17 | _RecordUpdate, |
18 | 18 | _RecordUpsert, |
| 19 | + _TableCreate, |
| 20 | + _TableDelete, |
19 | 21 | _TableGet, |
20 | 22 | _TableList, |
| 23 | + _TableAddColumns, |
| 24 | + _TableRemoveColumns, |
| 25 | + _TableCreateOneToMany, |
| 26 | + _TableCreateManyToMany, |
| 27 | + _TableDeleteRelationship, |
| 28 | + _TableGetRelationship, |
| 29 | + _TableCreateLookupField, |
21 | 30 | _QuerySql, |
22 | 31 | _extract_boundary, |
23 | 32 | _raise_top_level_batch_error, |
24 | | - _split_multipart, |
25 | 33 | _parse_mime_part, |
26 | 34 | _parse_http_response_part, |
27 | 35 | _CRLF, |
28 | 36 | ) |
29 | | -from PowerPlatform.Dataverse.core.errors import HttpError |
| 37 | +from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError, ValidationError |
30 | 38 | from PowerPlatform.Dataverse.models.upsert import UpsertItem |
31 | 39 | from PowerPlatform.Dataverse.data._raw_request import _RawRequest |
32 | | -from PowerPlatform.Dataverse.models.batch import BatchItemResponse |
33 | 40 |
|
34 | 41 |
|
35 | 42 | def _make_od(): |
@@ -389,12 +396,36 @@ def test_exceeds_1000_raises(self): |
389 | 396 | client = _BatchClient(od) |
390 | 397 |
|
391 | 398 | items = [_RecordGet(table="account", record_id=f"guid-{i}") for i in range(1001)] |
392 | | - from PowerPlatform.Dataverse.core.errors import ValidationError |
393 | | - |
394 | 399 | with self.assertRaises(ValidationError): |
395 | 400 | client.execute(items) |
396 | 401 |
|
397 | 402 |
|
| 403 | +class TestContinueOnError(unittest.TestCase): |
| 404 | + """execute() sends Prefer: odata.continue-on-error when requested.""" |
| 405 | + |
| 406 | + def setUp(self): |
| 407 | + self.od = _make_od() |
| 408 | + self.od._build_get.return_value = _RawRequest(method="GET", url="https://x/accounts(g)") |
| 409 | + mock_resp = MagicMock() |
| 410 | + mock_resp.headers = {"Content-Type": 'multipart/mixed; boundary="batch_x"'} |
| 411 | + mock_resp.status_code = 200 |
| 412 | + mock_resp.text = "--batch_x\r\n\r\nHTTP/1.1 204 No Content\r\n\r\n\r\n--batch_x--" |
| 413 | + self.od._request.return_value = mock_resp |
| 414 | + self.client = _BatchClient(self.od) |
| 415 | + |
| 416 | + def test_continue_on_error_header_sent(self): |
| 417 | + """Prefer: odata.continue-on-error header is included when continue_on_error=True.""" |
| 418 | + self.client.execute([_RecordGet(table="account", record_id="guid-1")], continue_on_error=True) |
| 419 | + _, kwargs = self.od._request.call_args |
| 420 | + self.assertEqual(kwargs.get("headers", {}).get("Prefer"), "odata.continue-on-error") |
| 421 | + |
| 422 | + def test_no_continue_on_error_header_by_default(self): |
| 423 | + """Prefer header is absent when continue_on_error is not set.""" |
| 424 | + self.client.execute([_RecordGet(table="account", record_id="guid-1")]) |
| 425 | + _, kwargs = self.od._request.call_args |
| 426 | + self.assertNotIn("Prefer", kwargs.get("headers", {})) |
| 427 | + |
| 428 | + |
398 | 429 | class TestChangeSetInternal(unittest.TestCase): |
399 | 430 | def test_add_create_returns_dollar_n(self): |
400 | 431 | cs = _ChangeSet() |
@@ -628,5 +659,287 @@ def test_parse_batch_response_raises_on_missing_boundary(self): |
628 | 659 | client._parse_batch_response(resp) |
629 | 660 |
|
630 | 661 |
|
| 662 | +class TestContinueOnError(unittest.TestCase): |
| 663 | + """execute() sends Prefer: odata.continue-on-error when requested.""" |
| 664 | + |
| 665 | + def setUp(self): |
| 666 | + self.od = _make_od() |
| 667 | + self.od._build_get.return_value = _RawRequest(method="GET", url="https://x/accounts(g)") |
| 668 | + mock_resp = MagicMock() |
| 669 | + mock_resp.headers = {"Content-Type": 'multipart/mixed; boundary="batch_x"'} |
| 670 | + mock_resp.status_code = 200 |
| 671 | + mock_resp.text = "--batch_x\r\n\r\nHTTP/1.1 204 No Content\r\n\r\n\r\n--batch_x--" |
| 672 | + self.od._request.return_value = mock_resp |
| 673 | + self.client = _BatchClient(self.od) |
| 674 | + |
| 675 | + def test_continue_on_error_header_sent(self): |
| 676 | + self.client.execute([_RecordGet(table="account", record_id="guid-1")], continue_on_error=True) |
| 677 | + _, kwargs = self.od._request.call_args |
| 678 | + self.assertEqual(kwargs.get("headers", {}).get("Prefer"), "odata.continue-on-error") |
| 679 | + |
| 680 | + def test_no_continue_on_error_header_by_default(self): |
| 681 | + self.client.execute([_RecordGet(table="account", record_id="guid-1")]) |
| 682 | + _, kwargs = self.od._request.call_args |
| 683 | + self.assertNotIn("Prefer", kwargs.get("headers", {})) |
| 684 | + |
| 685 | + |
| 686 | +class TestResolveItemDispatch(unittest.TestCase): |
| 687 | + """_resolve_item() routes each intent type to the correct resolver.""" |
| 688 | + |
| 689 | + def _client_and_od(self): |
| 690 | + od = _make_od() |
| 691 | + client = _BatchClient(od) |
| 692 | + return client, od |
| 693 | + |
| 694 | + def test_dispatch_record_update(self): |
| 695 | + """_resolve_item routes _RecordUpdate to _resolve_record_update.""" |
| 696 | + client, od = self._client_and_od() |
| 697 | + od._build_update.return_value = MagicMock() |
| 698 | + op = _RecordUpdate(table="account", ids="guid-1", changes={"name": "X"}) |
| 699 | + result = client._resolve_item(op) |
| 700 | + od._build_update.assert_called_once() |
| 701 | + self.assertEqual(len(result), 1) |
| 702 | + |
| 703 | + def test_dispatch_record_delete(self): |
| 704 | + """_resolve_item routes _RecordDelete to _resolve_record_delete.""" |
| 705 | + client, od = self._client_and_od() |
| 706 | + od._build_delete.return_value = MagicMock() |
| 707 | + op = _RecordDelete(table="account", ids="guid-1") |
| 708 | + result = client._resolve_item(op) |
| 709 | + od._build_delete.assert_called_once() |
| 710 | + self.assertEqual(len(result), 1) |
| 711 | + |
| 712 | + def test_dispatch_table_create(self): |
| 713 | + """_resolve_item routes _TableCreate to _build_create_entity.""" |
| 714 | + client, od = self._client_and_od() |
| 715 | + od._build_create_entity.return_value = MagicMock() |
| 716 | + op = _TableCreate(table="new_Widget", columns={"new_name": str}) |
| 717 | + result = client._resolve_item(op) |
| 718 | + od._build_create_entity.assert_called_once() |
| 719 | + self.assertEqual(len(result), 1) |
| 720 | + |
| 721 | + def test_dispatch_table_delete(self): |
| 722 | + """_resolve_item routes _TableDelete, resolving MetadataId before calling _build_delete_entity.""" |
| 723 | + client, od = self._client_and_od() |
| 724 | + od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"} |
| 725 | + od._build_delete_entity.return_value = MagicMock() |
| 726 | + op = _TableDelete(table="new_Widget") |
| 727 | + result = client._resolve_item(op) |
| 728 | + od._build_delete_entity.assert_called_once_with("meta-1") |
| 729 | + self.assertEqual(len(result), 1) |
| 730 | + |
| 731 | + def test_dispatch_table_get(self): |
| 732 | + """_resolve_item routes _TableGet to _build_get_entity.""" |
| 733 | + client, od = self._client_and_od() |
| 734 | + od._build_get_entity.return_value = MagicMock() |
| 735 | + op = _TableGet(table="account") |
| 736 | + result = client._resolve_item(op) |
| 737 | + od._build_get_entity.assert_called_once() |
| 738 | + self.assertEqual(len(result), 1) |
| 739 | + |
| 740 | + def test_dispatch_table_list(self): |
| 741 | + """_resolve_item routes _TableList to _build_list_entities.""" |
| 742 | + client, od = self._client_and_od() |
| 743 | + od._build_list_entities.return_value = MagicMock() |
| 744 | + op = _TableList() |
| 745 | + result = client._resolve_item(op) |
| 746 | + od._build_list_entities.assert_called_once() |
| 747 | + self.assertEqual(len(result), 1) |
| 748 | + |
| 749 | + def test_dispatch_table_add_columns(self): |
| 750 | + """_resolve_item routes _TableAddColumns, emitting one request per column.""" |
| 751 | + client, od = self._client_and_od() |
| 752 | + od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"} |
| 753 | + od._build_create_column.return_value = MagicMock() |
| 754 | + op = _TableAddColumns(table="account", columns={"new_col": str}) |
| 755 | + result = client._resolve_item(op) |
| 756 | + od._build_create_column.assert_called_once() |
| 757 | + self.assertEqual(len(result), 1) |
| 758 | + |
| 759 | + def test_dispatch_table_remove_columns(self): |
| 760 | + """_resolve_item routes _TableRemoveColumns, fetching attribute metadata before deleting.""" |
| 761 | + client, od = self._client_and_od() |
| 762 | + od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"} |
| 763 | + od._get_attribute_metadata.return_value = {"MetadataId": "attr-1"} |
| 764 | + od._build_delete_column.return_value = MagicMock() |
| 765 | + op = _TableRemoveColumns(table="account", columns="new_col") |
| 766 | + result = client._resolve_item(op) |
| 767 | + od._build_delete_column.assert_called_once() |
| 768 | + self.assertEqual(len(result), 1) |
| 769 | + |
| 770 | + def test_dispatch_table_create_one_to_many(self): |
| 771 | + """_resolve_item routes _TableCreateOneToMany, merging lookup into relationship body.""" |
| 772 | + client, od = self._client_and_od() |
| 773 | + od._build_create_relationship.return_value = MagicMock() |
| 774 | + lookup = MagicMock() |
| 775 | + lookup.to_dict.return_value = {} |
| 776 | + relationship = MagicMock() |
| 777 | + relationship.to_dict.return_value = {} |
| 778 | + op = _TableCreateOneToMany(lookup=lookup, relationship=relationship) |
| 779 | + result = client._resolve_item(op) |
| 780 | + od._build_create_relationship.assert_called_once() |
| 781 | + self.assertEqual(len(result), 1) |
| 782 | + |
| 783 | + def test_dispatch_table_create_many_to_many(self): |
| 784 | + """_resolve_item routes _TableCreateManyToMany to _build_create_relationship.""" |
| 785 | + client, od = self._client_and_od() |
| 786 | + od._build_create_relationship.return_value = MagicMock() |
| 787 | + relationship = MagicMock() |
| 788 | + relationship.to_dict.return_value = {} |
| 789 | + op = _TableCreateManyToMany(relationship=relationship) |
| 790 | + result = client._resolve_item(op) |
| 791 | + od._build_create_relationship.assert_called_once() |
| 792 | + self.assertEqual(len(result), 1) |
| 793 | + |
| 794 | + def test_dispatch_table_delete_relationship(self): |
| 795 | + """_resolve_item routes _TableDeleteRelationship, passing relationship_id.""" |
| 796 | + client, od = self._client_and_od() |
| 797 | + od._build_delete_relationship.return_value = MagicMock() |
| 798 | + op = _TableDeleteRelationship(relationship_id="rel-guid-1") |
| 799 | + result = client._resolve_item(op) |
| 800 | + od._build_delete_relationship.assert_called_once_with("rel-guid-1") |
| 801 | + self.assertEqual(len(result), 1) |
| 802 | + |
| 803 | + def test_dispatch_table_get_relationship(self): |
| 804 | + """_resolve_item routes _TableGetRelationship, passing schema_name.""" |
| 805 | + client, od = self._client_and_od() |
| 806 | + od._build_get_relationship.return_value = MagicMock() |
| 807 | + op = _TableGetRelationship(schema_name="new_account_contact") |
| 808 | + result = client._resolve_item(op) |
| 809 | + od._build_get_relationship.assert_called_once_with("new_account_contact") |
| 810 | + self.assertEqual(len(result), 1) |
| 811 | + |
| 812 | + def test_dispatch_table_create_lookup_field(self): |
| 813 | + """_resolve_item routes _TableCreateLookupField, building lookup and relationship models.""" |
| 814 | + client, od = self._client_and_od() |
| 815 | + lookup = MagicMock() |
| 816 | + lookup.to_dict.return_value = {} |
| 817 | + relationship = MagicMock() |
| 818 | + relationship.to_dict.return_value = {} |
| 819 | + od._build_lookup_field_models.return_value = (lookup, relationship) |
| 820 | + od._build_create_relationship.return_value = MagicMock() |
| 821 | + op = _TableCreateLookupField( |
| 822 | + referencing_table="new_Widget", |
| 823 | + lookup_field_name="new_accountid", |
| 824 | + referenced_table="account", |
| 825 | + ) |
| 826 | + result = client._resolve_item(op) |
| 827 | + od._build_lookup_field_models.assert_called_once() |
| 828 | + od._build_create_relationship.assert_called_once() |
| 829 | + self.assertEqual(len(result), 1) |
| 830 | + |
| 831 | + def test_dispatch_query_sql(self): |
| 832 | + """_resolve_item routes _QuerySql to _build_sql, passing the SQL string.""" |
| 833 | + client, od = self._client_and_od() |
| 834 | + od._build_sql.return_value = MagicMock() |
| 835 | + op = _QuerySql(sql="SELECT name FROM account") |
| 836 | + result = client._resolve_item(op) |
| 837 | + od._build_sql.assert_called_once_with("SELECT name FROM account") |
| 838 | + self.assertEqual(len(result), 1) |
| 839 | + |
| 840 | + |
| 841 | +class TestResolveOneChangeset(unittest.TestCase): |
| 842 | + """_resolve_one() raises ValidationError when operation produces != 1 request.""" |
| 843 | + |
| 844 | + def test_multi_request_op_in_changeset_raises(self): |
| 845 | + """use_bulk_delete=False with 2 ids produces 2 requests — not allowed in a changeset.""" |
| 846 | + od = _make_od() |
| 847 | + client = _BatchClient(od) |
| 848 | + od._build_delete.return_value = MagicMock() |
| 849 | + op = _RecordDelete(table="account", ids=["guid-1", "guid-2"], use_bulk_delete=False) |
| 850 | + with self.assertRaises(ValidationError): |
| 851 | + client._resolve_one(op) |
| 852 | + |
| 853 | + |
| 854 | +class TestRequireEntityMetadata(unittest.TestCase): |
| 855 | + """_require_entity_metadata raises MetadataError when table not found.""" |
| 856 | + |
| 857 | + def test_missing_entity_raises_metadata_error(self): |
| 858 | + """MetadataError raised when _get_entity_by_table_schema_name returns None.""" |
| 859 | + od = _make_od() |
| 860 | + od._get_entity_by_table_schema_name.return_value = None |
| 861 | + client = _BatchClient(od) |
| 862 | + with self.assertRaises(MetadataError): |
| 863 | + client._require_entity_metadata("new_Missing") |
| 864 | + |
| 865 | + def test_entity_without_metadata_id_raises(self): |
| 866 | + """MetadataError raised when entity exists but has no MetadataId field.""" |
| 867 | + od = _make_od() |
| 868 | + od._get_entity_by_table_schema_name.return_value = {"LogicalName": "new_missing"} |
| 869 | + client = _BatchClient(od) |
| 870 | + with self.assertRaises(MetadataError): |
| 871 | + client._require_entity_metadata("new_Missing") |
| 872 | + |
| 873 | + def test_valid_entity_returns_metadata_id(self): |
| 874 | + """Returns MetadataId string when entity is found and has a MetadataId.""" |
| 875 | + od = _make_od() |
| 876 | + od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-abc"} |
| 877 | + client = _BatchClient(od) |
| 878 | + result = client._require_entity_metadata("account") |
| 879 | + self.assertEqual(result, "meta-abc") |
| 880 | + |
| 881 | + |
| 882 | +class TestTableRemoveColumnsResolver(unittest.TestCase): |
| 883 | + """_resolve_table_remove_columns covers string input and missing column error.""" |
| 884 | + |
| 885 | + def _client_and_od(self): |
| 886 | + od = _make_od() |
| 887 | + client = _BatchClient(od) |
| 888 | + return client, od |
| 889 | + |
| 890 | + def test_single_string_column_resolved(self): |
| 891 | + """A single string column name is accepted and resolved to one delete request.""" |
| 892 | + client, od = self._client_and_od() |
| 893 | + od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"} |
| 894 | + od._get_attribute_metadata.return_value = {"MetadataId": "attr-1"} |
| 895 | + od._build_delete_column.return_value = MagicMock() |
| 896 | + op = _TableRemoveColumns(table="account", columns="new_col") |
| 897 | + result = client._resolve_table_remove_columns(op) |
| 898 | + self.assertEqual(len(result), 1) |
| 899 | + |
| 900 | + def test_missing_column_raises_metadata_error(self): |
| 901 | + """MetadataError raised when attribute metadata is not found for the column.""" |
| 902 | + client, od = self._client_and_od() |
| 903 | + od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"} |
| 904 | + od._get_attribute_metadata.return_value = None |
| 905 | + op = _TableRemoveColumns(table="account", columns="new_missing") |
| 906 | + with self.assertRaises(MetadataError): |
| 907 | + client._resolve_table_remove_columns(op) |
| 908 | + |
| 909 | + def test_column_without_metadata_id_raises(self): |
| 910 | + """MetadataError raised when attribute metadata exists but has no MetadataId.""" |
| 911 | + client, od = self._client_and_od() |
| 912 | + od._get_entity_by_table_schema_name.return_value = {"MetadataId": "meta-1"} |
| 913 | + od._get_attribute_metadata.return_value = {"AttributeType": "String"} |
| 914 | + op = _TableRemoveColumns(table="account", columns="new_col") |
| 915 | + with self.assertRaises(MetadataError): |
| 916 | + client._resolve_table_remove_columns(op) |
| 917 | + |
| 918 | + |
| 919 | +class TestParseMimePartNoSeparator(unittest.TestCase): |
| 920 | + """_parse_mime_part handles raw string with no blank-line separator.""" |
| 921 | + |
| 922 | + def test_no_double_newline_returns_empty_body(self): |
| 923 | + """When raw part has no blank-line separator, headers are parsed and body is empty.""" |
| 924 | + raw = "Content-Type: application/http" |
| 925 | + headers, body = _parse_mime_part(raw) |
| 926 | + self.assertEqual(headers.get("content-type"), "application/http") |
| 927 | + self.assertEqual(body, "") |
| 928 | + |
| 929 | + |
| 930 | +class TestParseHttpResponsePartMalformed(unittest.TestCase): |
| 931 | + """_parse_http_response_part returns None for malformed status lines.""" |
| 932 | + |
| 933 | + def test_status_line_too_short_returns_none(self): |
| 934 | + """Returns None when status line has fewer than 2 tokens (no status code).""" |
| 935 | + result = _parse_http_response_part("HTTP/1.1\r\n\r\n", content_id=None) |
| 936 | + self.assertIsNone(result) |
| 937 | + |
| 938 | + def test_non_integer_status_code_returns_none(self): |
| 939 | + """Returns None when status code token is not a valid integer.""" |
| 940 | + result = _parse_http_response_part("HTTP/1.1 XYZ OK\r\n\r\n", content_id=None) |
| 941 | + self.assertIsNone(result) |
| 942 | + |
| 943 | + |
631 | 944 | if __name__ == "__main__": |
632 | 945 | unittest.main() |
0 commit comments