sfq 0.0.27__tar.gz → 0.0.29__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfq
3
- Version: 0.0.27
3
+ Version: 0.0.29
4
4
  Summary: Python wrapper for the Salesforce's Query API.
5
5
  Author-email: David Moruzzi <sfq.pypi@dmoruzi.com>
6
6
  Keywords: salesforce,salesforce query
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sfq"
3
- version = "0.0.27"
3
+ version = "0.0.29"
4
4
  description = "Python wrapper for the Salesforce's Query API."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "David Moruzzi", email = "sfq.pypi@dmoruzi.com" }]
@@ -7,11 +7,13 @@ import http.client
7
7
  import json
8
8
  import logging
9
9
  import os
10
+ import re
10
11
  import time
11
12
  import warnings
13
+ import xml.etree.ElementTree as ET
12
14
  from collections import OrderedDict
13
15
  from concurrent.futures import ThreadPoolExecutor, as_completed
14
- from typing import Any, Dict, Iterable, Literal, Optional, List, Tuple
16
+ from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
15
17
  from urllib.parse import quote, urlparse
16
18
 
17
19
  __all__ = ["SFAuth"] # https://pdoc.dev/docs/pdoc.html#control-what-is-documented
@@ -24,7 +26,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
24
26
  """Custom TRACE level logging function with redaction."""
25
27
 
26
28
  def _redact_sensitive(data: Any) -> Any:
27
- """Redacts sensitive keys from a dictionary or query string."""
29
+ """Redacts sensitive keys from a dictionary, query string, or sessionId."""
28
30
  REDACT_VALUE = "*" * 8
29
31
  REDACT_KEYS = [
30
32
  "access_token",
@@ -33,6 +35,7 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
33
35
  "cookie",
34
36
  "refresh_token",
35
37
  "client_secret",
38
+ "sessionid",
36
39
  ]
37
40
  if isinstance(data, dict):
38
41
  return {
@@ -49,6 +52,12 @@ def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None
49
52
  )
50
53
  )
51
54
  elif isinstance(data, str):
55
+ if "<sessionId>" in data and "</sessionId>" in data:
56
+ data = re.sub(
57
+ r"(<sessionId>)(.*?)(</sessionId>)",
58
+ r"\1{}\3".format(REDACT_VALUE),
59
+ data,
60
+ )
52
61
  parts = data.split("&")
53
62
  for i, part in enumerate(parts):
54
63
  if "=" in part:
@@ -90,7 +99,7 @@ class SFAuth:
90
99
  access_token: Optional[str] = None,
91
100
  token_expiration_time: Optional[float] = None,
92
101
  token_lifetime: int = 15 * 60,
93
- user_agent: str = "sfq/0.0.27",
102
+ user_agent: str = "sfq/0.0.29",
94
103
  sforce_client: str = "_auto",
95
104
  proxy: str = "_auto",
96
105
  ) -> None:
@@ -828,7 +837,9 @@ class SFAuth:
828
837
  for i, result in enumerate(results):
829
838
  key = batch_keys[i]
830
839
  if result.get("statusCode") == 200 and "result" in result:
831
- paginated = self._paginate_query_result(result["result"], headers)
840
+ paginated = self._paginate_query_result(
841
+ result["result"], headers
842
+ )
832
843
  batch_results[key] = paginated
833
844
  else:
834
845
  logger.error("Query failed for key %s: %s", key, result)
@@ -854,7 +865,9 @@ class SFAuth:
854
865
  for i in range(0, len(keys), BATCH_SIZE):
855
866
  batch_keys = keys[i : i + BATCH_SIZE]
856
867
  batch_queries = [query_dict[key] for key in batch_keys]
857
- futures.append(executor.submit(_execute_batch, batch_keys, batch_queries))
868
+ futures.append(
869
+ executor.submit(_execute_batch, batch_keys, batch_queries)
870
+ )
858
871
 
859
872
  for future in as_completed(futures):
860
873
  results_dict.update(future.result())
@@ -910,12 +923,14 @@ class SFAuth:
910
923
  ]
911
924
  return combined_response or None
912
925
 
913
- def _cupdate(self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None) -> Optional[Dict[str, Any]]:
926
+ def _cupdate(
927
+ self, update_dict: Dict[str, Any], batch_size: int = 25, max_workers: int = None
928
+ ) -> Optional[Dict[str, Any]]:
914
929
  """
915
930
  Execute the Composite Update API to update multiple records.
916
931
 
917
932
  :param update_dict: A dictionary of keys of records to be updated, and a dictionary of field-value pairs to be updated, with a special key '_' overriding the sObject type which is otherwise inferred from the key. Example:
918
- {'001aj00000C8kJhAAJ': {'Subject': 'Easily updated via SFQ'}, '00aaj000006wtdcAAA': {'_': 'CaseComment', 'IsPublished': False}, '001aj0000002yJRCAY': {'_': 'IdeaComment', 'CommentBody': 'Hello World!'}}
933
+ {'001aj00000C8kJhAAJ': {'Subject': 'Easily updated via SFQ'}, '00aaj000006wtdcAAA': {'_': 'CaseComment', 'IsPublished': False}, '001aj0000002yJRCAY': {'_': 'IdeaComment', 'CommentBody': 'Hello World!'}}
919
934
  :param batch_size: The number of records to update in each batch (default is 25).
920
935
  :return: JSON response from the update request or None on failure.
921
936
  """
@@ -929,26 +944,26 @@ class SFAuth:
929
944
  sobject = record.copy().pop("_", None)
930
945
  if not sobject and not sobject_prefixes:
931
946
  sobject_prefixes = self.get_sobject_prefixes()
932
-
947
+
933
948
  if not sobject:
934
949
  sobject = str(sobject_prefixes.get(str(key[:3]), None))
935
-
950
+
936
951
  compositeRequest_payload.append(
937
952
  {
938
- 'method': 'PATCH',
939
- 'url': f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
940
- 'referenceId': key,
941
- 'body': record,
953
+ "method": "PATCH",
954
+ "url": f"/services/data/{self.api_version}/sobjects/{sobject}/{key}",
955
+ "referenceId": key,
956
+ "body": record,
942
957
  }
943
958
  )
944
959
 
945
- chunks = [compositeRequest_payload[i:i+batch_size] for i in range(0, len(compositeRequest_payload), batch_size)]
960
+ chunks = [
961
+ compositeRequest_payload[i : i + batch_size]
962
+ for i in range(0, len(compositeRequest_payload), batch_size)
963
+ ]
946
964
 
947
965
  def update_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
948
- payload = {
949
- "allOrNone": bool(allOrNone),
950
- "compositeRequest": chunk
951
- }
966
+ payload = {"allOrNone": bool(allOrNone), "compositeRequest": chunk}
952
967
 
953
968
  status_code, resp_data = self._send_request(
954
969
  method="POST",
@@ -979,5 +994,174 @@ class SFAuth:
979
994
  for item in (result if isinstance(result, list) else [result])
980
995
  if isinstance(result, (dict, list))
981
996
  ]
982
-
997
+
998
+ return combined_response or None
999
+
1000
+ def _gen_soap_envelope(self, header: str, body: str) -> str:
1001
+ """Generates a full SOAP envelope with all required namespaces for Salesforce Enterprise API."""
1002
+ return (
1003
+ '<?xml version="1.0" encoding="UTF-8"?>'
1004
+ "<soapenv:Envelope "
1005
+ 'xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" '
1006
+ 'xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
1007
+ 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
1008
+ 'xmlns="urn:enterprise.soap.sforce.com" '
1009
+ 'xmlns:sf="urn:sobject.enterprise.soap.sforce.com">'
1010
+ f"{header}{body}"
1011
+ "</soapenv:Envelope>"
1012
+ )
1013
+
1014
+ def _gen_soap_header(self):
1015
+ """This function generates the header for the SOAP request."""
1016
+ headers = self._get_common_headers()
1017
+ session_id = headers["Authorization"].split(" ")[1]
1018
+ return f"<soapenv:Header><SessionHeader><sessionId>{session_id}</sessionId></SessionHeader></soapenv:Header>"
1019
+
1020
+ def _extract_soap_result_fields(self, xml_string: str) -> Optional[Dict[str, Any]]:
1021
+ """
1022
+ Parse SOAP XML and extract all child fields from <result> as a dict.
1023
+ """
1024
+
1025
+ def strip_ns(tag):
1026
+ return tag.split("}", 1)[-1] if "}" in tag else tag
1027
+
1028
+ try:
1029
+ root = ET.fromstring(xml_string)
1030
+ results = []
1031
+ for result in root.iter():
1032
+ if result.tag.endswith("result"):
1033
+ out = {}
1034
+ for child in result:
1035
+ out[strip_ns(child.tag)] = child.text
1036
+ results.append(out)
1037
+ if not results:
1038
+ return None
1039
+ if len(results) == 1:
1040
+ return results[0]
1041
+ return results
1042
+ except ET.ParseError as e:
1043
+ logger.error("Failed to parse SOAP XML: %s", e)
1044
+ return None
1045
+
1046
+ def _gen_soap_body(self, sobject: str, method: str, data: Dict[str, Any]) -> str:
1047
+ """Generates a compact SOAP request body for one or more records."""
1048
+ # Accept both a single dict and a list of dicts
1049
+ if isinstance(data, dict):
1050
+ records = [data]
1051
+ else:
1052
+ records = data
1053
+ sobjects = "".join(
1054
+ f'<sObjects xsi:type="{sobject}">'
1055
+ + "".join(f"<{k}>{v}</{k}>" for k, v in record.items())
1056
+ + "</sObjects>"
1057
+ for record in records
1058
+ )
1059
+ return f"<soapenv:Body><{method}>{sobjects}</{method}></soapenv:Body>"
1060
+
1061
+ def _xml_to_json(self, xml_string: str) -> Optional[Dict[str, Any]]:
1062
+ """
1063
+ Convert an XML string to a JSON-like dictionary.
1064
+
1065
+ :param xml_string: The XML string to convert.
1066
+ :return: A dictionary representation of the XML or None on failure.
1067
+ """
1068
+ try:
1069
+ root = ET.fromstring(xml_string)
1070
+ return self._xml_to_dict(root)
1071
+ except ET.ParseError as e:
1072
+ logger.error("Failed to parse XML: %s", e)
1073
+ return None
1074
+
1075
+ def _xml_to_dict(self, element: ET.Element) -> Dict[str, Any]:
1076
+ """
1077
+ Recursively convert an XML Element to a dictionary.
1078
+
1079
+ :param element: The XML Element to convert.
1080
+ :return: A dictionary representation of the XML Element.
1081
+ """
1082
+ if len(element) == 0:
1083
+ return element.text or ""
1084
+
1085
+ result = {}
1086
+ for child in element:
1087
+ child_dict = self._xml_to_dict(child)
1088
+ if child.tag not in result:
1089
+ result[child.tag] = child_dict
1090
+ else:
1091
+ if not isinstance(result[child.tag], list):
1092
+ result[child.tag] = [result[child.tag]]
1093
+ result[child.tag].append(child_dict)
1094
+ return result
1095
+
1096
+ def _create( # I don't like this name, will think of a better one later...as such, not public.
1097
+ self,
1098
+ sobject: str,
1099
+ insert_list: List[Dict[str, Any]],
1100
+ batch_size: int = 200,
1101
+ max_workers: int = None,
1102
+ ) -> Optional[Dict[str, Any]]:
1103
+ """
1104
+ Execute the Insert API to insert multiple records via SOAP calls.
1105
+
1106
+ :param sobject: The name of the sObject to insert into.
1107
+ :param insert_list: A list of dictionaries, each representing a record to insert. Example: [{'Subject': 'Easily inserted via SFQ'}]
1108
+ :param batch_size: The number of records to insert in each batch (default is 200).
1109
+ :param max_workers: The maximum number of threads to spawn for concurrent execution (default is None).
1110
+ :return: JSON response from the insert request or None on failure.
1111
+ """
1112
+
1113
+ endpoint = f"/services/Soap/c/{self.api_version}"
1114
+
1115
+ if isinstance(insert_list, dict):
1116
+ insert_list = [insert_list]
1117
+
1118
+ chunks = [
1119
+ insert_list[i : i + batch_size]
1120
+ for i in range(0, len(insert_list), batch_size)
1121
+ ]
1122
+
1123
+ def insert_chunk(chunk: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
1124
+ header = self._gen_soap_header()
1125
+ body = self._gen_soap_body(sobject=sobject, method="create", data=chunk)
1126
+ envelope = self._gen_soap_envelope(header, body)
1127
+ soap_headers = self._get_common_headers().copy()
1128
+ soap_headers["Content-Type"] = "text/xml; charset=UTF-8"
1129
+ soap_headers["SOAPAction"] = '""'
1130
+
1131
+ logger.trace("SOAP request envelope: %s", envelope)
1132
+ logger.trace("SOAP request headers: %s", soap_headers)
1133
+ status_code, resp_data = self._send_request(
1134
+ method="POST",
1135
+ endpoint=endpoint,
1136
+ headers=soap_headers,
1137
+ body=envelope,
1138
+ )
1139
+
1140
+ if status_code == 200:
1141
+ logger.debug("Insert API request successful.")
1142
+ logger.trace("Insert API response: %s", resp_data)
1143
+ result = self._extract_soap_result_fields(resp_data)
1144
+ if result:
1145
+ return result
1146
+ logger.error("Failed to extract fields from SOAP response.")
1147
+ else:
1148
+ logger.error("Insert API request failed: %s", status_code)
1149
+ logger.debug("Response body: %s", resp_data)
1150
+ return None
1151
+
1152
+ results = []
1153
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1154
+ futures = [executor.submit(insert_chunk, chunk) for chunk in chunks]
1155
+ for future in as_completed(futures):
1156
+ result = future.result()
1157
+ if result:
1158
+ results.append(result)
1159
+
1160
+ combined_response = [
1161
+ item
1162
+ for result in results
1163
+ for item in (result if isinstance(result, list) else [result])
1164
+ if isinstance(result, (dict, list))
1165
+ ]
1166
+
983
1167
  return combined_response or None
@@ -0,0 +1,73 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ # --- Setup local import path ---
8
+ project_root = Path(__file__).resolve().parents[1]
9
+ src_path = project_root / "src"
10
+ sys.path.insert(0, str(src_path))
11
+ from sfq import SFAuth # noqa: E402
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def sf_instance():
16
+ required_env_vars = [
17
+ "SF_INSTANCE_URL",
18
+ "SF_CLIENT_ID",
19
+ "SF_CLIENT_SECRET",
20
+ "SF_REFRESH_TOKEN",
21
+ ]
22
+
23
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
24
+ if missing_vars:
25
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
26
+
27
+ sf = SFAuth(
28
+ instance_url=os.getenv("SF_INSTANCE_URL"),
29
+ client_id=os.getenv("SF_CLIENT_ID"),
30
+ client_secret=os.getenv("SF_CLIENT_SECRET"),
31
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
32
+ )
33
+ return sf
34
+
35
+
36
+ def test_cdelete(sf_instance):
37
+ """Ensure that a simple delete returns the expected results."""
38
+ query = "SELECT Id FROM FeedComment LIMIT 1"
39
+ response = sf_instance.query(query)
40
+ assert response and response["records"], "No FeedComment found for test."
41
+ feed_comment_id = response["records"][0]["Id"]
42
+
43
+ result = sf_instance.cdelete([feed_comment_id])
44
+ assert result and isinstance(result, list), (
45
+ f"Delete did not return a list: {result}"
46
+ )
47
+
48
+ assert result[0].get("success"), f"Delete failed: {result}"
49
+ assert "id" in result[0], f"No ID returned: {result}"
50
+
51
+
52
+ def test_cdelete_batch(sf_instance):
53
+ """Test batching/pagination: Delete multiple FeedComment records and ensure batching works."""
54
+ query = "SELECT Id FROM FeedComment LIMIT 250"
55
+ response = sf_instance.query(query)
56
+ assert response and response["records"], "No FeedComment found for test."
57
+
58
+ response_size = len(response["records"])
59
+ if response_size < 201:
60
+ pytest.skip("Not enough FeedComment records for batch delete test.")
61
+
62
+ feed_comment_ids = [record["Id"] for record in response["records"]]
63
+
64
+ result = sf_instance.cdelete(feed_comment_ids)
65
+ assert result and isinstance(result, list), (
66
+ f"Delete did not return a list: {result}"
67
+ )
68
+
69
+ assert all(item.get("success") for item in result), f"Delete failed: {result}"
70
+
71
+ assert len(result) == response_size, (
72
+ f"Expected {response_size} results, got {len(result)}: {result}"
73
+ )
@@ -0,0 +1,66 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ # --- Setup local import path ---
8
+ project_root = Path(__file__).resolve().parents[1]
9
+ src_path = project_root / "src"
10
+ sys.path.insert(0, str(src_path))
11
+ from sfq import SFAuth # noqa: E402
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def sf_instance():
16
+ required_env_vars = [
17
+ "SF_INSTANCE_URL",
18
+ "SF_CLIENT_ID",
19
+ "SF_CLIENT_SECRET",
20
+ "SF_REFRESH_TOKEN",
21
+ ]
22
+
23
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
24
+ if missing_vars:
25
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
26
+
27
+ sf = SFAuth(
28
+ instance_url=os.getenv("SF_INSTANCE_URL"),
29
+ client_id=os.getenv("SF_CLIENT_ID"),
30
+ client_secret=os.getenv("SF_CLIENT_SECRET"),
31
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
32
+ )
33
+ return sf
34
+
35
+
36
+ def test_simple_query(sf_instance):
37
+ """Ensure that a simple query returns the expected results."""
38
+ result = sf_instance.cquery({'refId': 'SELECT Id FROM Organization LIMIT 1'})
39
+
40
+ sf_api_version = sf_instance.api_version
41
+ expected = {
42
+ "totalSize": 1,
43
+ "done": True,
44
+ "records": [
45
+ {
46
+ "attributes": {
47
+ "type": "Organization",
48
+ "url": f"/services/data/{sf_api_version}/sobjects/Organization/00Daj000004ej9WEAQ",
49
+ },
50
+ "Id": "00Daj000004ej9WEAQ",
51
+ }
52
+ ],
53
+ }
54
+
55
+ assert result["refId"]["done"]
56
+ assert result["refId"]["totalSize"] == 1
57
+ assert len(result["refId"]["records"]) == 1
58
+ assert result["refId"] == expected
59
+
60
+ def test_cquery_with_pagination(sf_instance):
61
+ """Ensure that query pagination is functioning"""
62
+ result = sf_instance.cquery({"refId": "SELECT Id FROM FeedComment LIMIT 2200"})
63
+
64
+ assert len(result["refId"]["records"]) == 2200
65
+ assert result["refId"]["totalSize"] == 2200
66
+ assert result["refId"]["done"]
@@ -0,0 +1,80 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ # --- Setup local import path ---
8
+ project_root = Path(__file__).resolve().parents[1]
9
+ src_path = project_root / "src"
10
+ sys.path.insert(0, str(src_path))
11
+ from sfq import SFAuth # noqa: E402
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def sf_instance():
16
+ required_env_vars = [
17
+ "SF_INSTANCE_URL",
18
+ "SF_CLIENT_ID",
19
+ "SF_CLIENT_SECRET",
20
+ "SF_REFRESH_TOKEN",
21
+ ]
22
+
23
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
24
+ if missing_vars:
25
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
26
+
27
+ sf = SFAuth(
28
+ instance_url=os.getenv("SF_INSTANCE_URL"),
29
+ client_id=os.getenv("SF_CLIENT_ID"),
30
+ client_secret=os.getenv("SF_CLIENT_SECRET"),
31
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
32
+ )
33
+ return sf
34
+
35
+
36
+ def get_feed_item_id(sf_instance):
37
+ """Helper to fetch a valid FeedItemId for tests."""
38
+ response = sf_instance.query("SELECT Id FROM FeedItem LIMIT 1")
39
+ assert response and response["records"], "No FeedItem found for test."
40
+ return response["records"][0]["Id"]
41
+
42
+
43
+ def test_feed_comment_insertion(sf_instance):
44
+ """Ensure that a simple insert returns the expected results."""
45
+ feed_item_id = get_feed_item_id(sf_instance)
46
+ comment = {
47
+ "FeedItemId": feed_item_id,
48
+ "CommentBody": f"Test comment via {sf_instance.user_agent}",
49
+ }
50
+ result = sf_instance._create("FeedComment", [comment])
51
+ assert result and isinstance(result, list), (
52
+ f"Create did not return a list: {result}"
53
+ )
54
+ assert result[0].get("success"), f"Insert failed: {result}"
55
+ assert "id" in result[0], f"No ID returned: {result}"
56
+
57
+
58
+ def test_feed_comment_batch_insertion(sf_instance):
59
+ """Test batching/pagination: Insert multiple FeedComment records and ensure batching works."""
60
+ feed_item_id = get_feed_item_id(sf_instance)
61
+ comments = [
62
+ {
63
+ "FeedItemId": feed_item_id,
64
+ "CommentBody": f"Batch comment {i} via {sf_instance.user_agent}",
65
+ }
66
+ for i in range(250)
67
+ ]
68
+ results = sf_instance._create("FeedComment", comments)
69
+ assert results and isinstance(results, list), (
70
+ f"Batch create did not return a list: {results}"
71
+ )
72
+ assert len(results) == 250, f"Expected 250 results, got {len(results)}"
73
+ successes = [
74
+ r
75
+ for r in results
76
+ if str(r.get("success")).lower() == "true" or r.get("success") is True
77
+ ]
78
+ assert len(successes) == 250, f"Not all inserts succeeded: {len(successes)}"
79
+ for r in results:
80
+ assert "id" in r, f"Result missing 'id': {r}"
@@ -0,0 +1,112 @@
1
+ import random
2
+ import os
3
+ import sys
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ # --- Setup local import path ---
10
+ project_root = Path(__file__).resolve().parents[1]
11
+ src_path = project_root / "src"
12
+ sys.path.insert(0, str(src_path))
13
+ from sfq import SFAuth # noqa: E402
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def sf_instance():
18
+ required_env_vars = [
19
+ "SF_INSTANCE_URL",
20
+ "SF_CLIENT_ID",
21
+ "SF_CLIENT_SECRET",
22
+ "SF_REFRESH_TOKEN",
23
+ ]
24
+
25
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
26
+ if missing_vars:
27
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
28
+
29
+ sf = SFAuth(
30
+ instance_url=os.getenv("SF_INSTANCE_URL"),
31
+ client_id=os.getenv("SF_CLIENT_ID"),
32
+ client_secret=os.getenv("SF_CLIENT_SECRET"),
33
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
34
+ )
35
+ return sf
36
+
37
+
38
+ def test_simple_cupdate(sf_instance):
39
+ """Ensure that a simple update returns the expected results."""
40
+ query = "SELECT Id, CommentBody FROM FeedComment LIMIT 1"
41
+ response = sf_instance.query(query)
42
+ assert response and response["records"], "No FeedComment found for test."
43
+
44
+ update_dict = dict()
45
+ feed_comment_id = response["records"][0]["Id"]
46
+ update_dict[feed_comment_id] = {"CommentBody": f"Evaluated at {datetime.now()}"}
47
+
48
+ result = sf_instance._cupdate(update_dict)
49
+
50
+ # Validate the result
51
+ assert result, "CUpdate did not return a result."
52
+ assert isinstance(result, list), "CUpdate result is not a list."
53
+ assert len(result) == 1, "CUpdate result should contain one response."
54
+ assert "compositeResponse" in result[0], "CUpdate response does not contain 'compositeResponse'."
55
+ assert len(result[0]["compositeResponse"]) == 1, "CUpdate response should contain one composite response."
56
+ assert result[0]["compositeResponse"][0]["httpStatusCode"] == 204, "CUpdate did not return HTTP status 204."
57
+ assert result[0]["compositeResponse"][0]["body"] is None, "CUpdate response body should be None."
58
+
59
+ # Verify that the update was successful by querying the updated record
60
+ new_query = f"SELECT Id, CommentBody FROM FeedComment WHERE Id = '{feed_comment_id}'"
61
+ updated_response = sf_instance.query(new_query)
62
+ assert updated_response and updated_response["records"], "No updated FeedComment found."
63
+ assert len(updated_response["records"]) == 1, "Expected one updated FeedComment."
64
+ assert updated_response["records"][0]["CommentBody"] == update_dict[feed_comment_id]["CommentBody"], "Updated CommentBody does not match."
65
+
66
+ def test_cupdate_with_invalid_data(sf_instance):
67
+ """Test cupdate with invalid data to ensure it raises an error."""
68
+ update_dict = {"invalid_id": {"CommentBody": "This should fail"}}
69
+
70
+ res = sf_instance._cupdate(update_dict)
71
+ assert res, "CUpdate did not return a result."
72
+ assert isinstance(res, list), "CUpdate result is not a list."
73
+ assert len(res) == 1, "CUpdate result should contain one response."
74
+ assert "compositeResponse" in res[0], "CUpdate response does not contain 'compositeResponse'."
75
+ assert len(res[0]["compositeResponse"]) == 1, "CUpdate response should contain one composite response."
76
+ assert res[0]["compositeResponse"][0]["httpStatusCode"] == 404, "CUpdate did not return HTTP status 404."
77
+ assert res[0]["compositeResponse"][0]["body"][0]["errorCode"] == "NOT_FOUND", "Expected NOT_FOUND error code."
78
+ assert res[0]["compositeResponse"][0]["body"][0]["message"] == "The requested resource does not exist", "Expected NOT_FOUND error message."
79
+
80
+ def test_cupdate_pagination(sf_instance):
81
+ """Test cupdate with pagination to ensure it handles multiple records."""
82
+ size = 35 # using `/services/data/{self.api_version}/sobjects/{sobject}/{key}` which is 25 per pagination; I want to migrate to Soap for larger 200 batches.
83
+ query = f"SELECT Id, CommentBody FROM FeedComment LIMIT {size}"
84
+ initial_query_response = sf_instance.query(query)
85
+
86
+ response_count = len(initial_query_response["records"])
87
+ assert response_count == size, f"Expected {size} records, got {response_count}."
88
+ ids_updated = {record["Id"] for record in initial_query_response["records"]}
89
+
90
+ update_dict = dict()
91
+ for record in initial_query_response["records"]:
92
+ feed_comment_id = record["Id"]
93
+ update_dict[feed_comment_id] = {"CommentBody": f"sfq/{datetime.now().timestamp()} #{random.randint(1000, 9999)}"}
94
+
95
+ update_response = sf_instance._cupdate(update_dict)
96
+ assert update_response, "CUpdate did not return a result."
97
+ assert isinstance(update_response, list), "CUpdate result is not a list."
98
+
99
+ subset_ids = random.sample(list(ids_updated), min(50, len(ids_updated)))
100
+ subset_id_str = ", ".join([f"'{id_}'" for id_ in subset_ids])
101
+ subset_query = f"SELECT Id, CommentBody FROM FeedComment WHERE Id IN ({subset_id_str})"
102
+ updated_response = sf_instance.query(subset_query)
103
+
104
+ # Validate the updated records
105
+ assert updated_response, "Query for updated records did not return a result."
106
+ assert updated_response and updated_response["records"], "No updated FeedComment found."
107
+ assert len(updated_response["records"]) == len(subset_ids), "Expected updated records count does not match subset size."
108
+
109
+ # validate that all updated records have the expected CommentBody
110
+ for record in updated_response["records"]:
111
+ expected_comment_body = update_dict[record["Id"]]["CommentBody"]
112
+ assert record["CommentBody"] == expected_comment_body, f"Updated CommentBody for {record['Id']} does not match expected value."
@@ -77,3 +77,56 @@ def test_access_token_redacted_in_logs(sf_instance, capture_logs):
77
77
  assert "'access_token': '********'," in log_contents in log_contents, (
78
78
  "Access token was not properly redacted in logs"
79
79
  )
80
+
81
+
82
+ def test_soap_sessionid_redacted_in_logs(sf_instance, capture_logs):
83
+ """
84
+ Ensure SOAP sessionId is redacted in log output to prevent leakage.
85
+ """
86
+ logger, log_stream = capture_logs
87
+
88
+ soap_header = sf_instance._gen_soap_header()
89
+ logger.trace("SOAP header payload: %s", soap_header)
90
+
91
+ logger.handlers[0].flush()
92
+ log_contents = log_stream.getvalue()
93
+
94
+ assert "<sessionId>" in log_contents, "Expected <sessionId> tag in logs"
95
+ assert f"<sessionId>{'*' * 8}</sessionId>" in log_contents, (
96
+ "SOAP sessionId was not properly redacted in logs"
97
+ )
98
+
99
+
100
+ def test_soap_create_redaction(sf_instance, capture_logs):
101
+ """
102
+ Ensure SOAP create operation does not leak sensitive information in logs.
103
+ """
104
+ logger, log_stream = capture_logs
105
+
106
+ create_response = sf_instance._create("Account", [{"Name": "Test Account"}])
107
+ logger.trace("Creating Account: %s", create_response)
108
+
109
+ created_ids = [
110
+ item["id"]
111
+ for item in create_response
112
+ if item.get("success") is True and "id" in item
113
+ ]
114
+
115
+ if created_ids:
116
+ del_response = sf_instance.cdelete(created_ids)
117
+ logger.trace("Deleting created Account: %s", del_response)
118
+
119
+ logger.handlers[0].flush()
120
+ log_contents = log_stream.getvalue()
121
+
122
+ for acc_id in created_ids:
123
+ assert acc_id in log_contents, f"Expected account ID {acc_id} in logs"
124
+
125
+ assert "<sessionId>" in log_contents, (
126
+ "SOAP sessionId should be logged, but redacted"
127
+ )
128
+ assert f"<sessionId>{'*' * 8}</sessionId>" in log_contents, (
129
+ "SOAP sessionId should be logged in redacted form, but was not"
130
+ )
131
+
132
+ assert "access_token" not in log_contents, "Access token should not be logged"
@@ -0,0 +1,126 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ # --- Setup local import path ---
8
+ project_root = Path(__file__).resolve().parents[1]
9
+ src_path = project_root / "src"
10
+ sys.path.insert(0, str(src_path))
11
+ from sfq import SFAuth # noqa: E402
12
+
13
+
14
+ @pytest.fixture(scope="module")
15
+ def sf_instance():
16
+ required_env_vars = [
17
+ "SF_INSTANCE_URL",
18
+ "SF_CLIENT_ID",
19
+ "SF_CLIENT_SECRET",
20
+ "SF_REFRESH_TOKEN",
21
+ ]
22
+
23
+ missing_vars = [var for var in required_env_vars if not os.getenv(var)]
24
+ if missing_vars:
25
+ pytest.fail(f"Missing required env vars: {', '.join(missing_vars)}")
26
+
27
+ sf = SFAuth(
28
+ instance_url=os.getenv("SF_INSTANCE_URL"),
29
+ client_id=os.getenv("SF_CLIENT_ID"),
30
+ client_secret=os.getenv("SF_CLIENT_SECRET"),
31
+ refresh_token=os.getenv("SF_REFRESH_TOKEN"),
32
+ )
33
+ return sf
34
+
35
+
36
+ def test_simple_query(sf_instance):
37
+ """Ensure that a simple query returns the expected results."""
38
+ result = sf_instance.query("SELECT Id FROM Organization LIMIT 1")
39
+
40
+ sf_api_version = sf_instance.api_version
41
+ expected = {
42
+ "totalSize": 1,
43
+ "done": True,
44
+ "records": [
45
+ {
46
+ "attributes": {
47
+ "type": "Organization",
48
+ "url": f"/services/data/{sf_api_version}/sobjects/Organization/00Daj000004ej9WEAQ",
49
+ },
50
+ "Id": "00Daj000004ej9WEAQ",
51
+ }
52
+ ],
53
+ }
54
+
55
+ assert result["done"]
56
+ assert result["totalSize"] == 1
57
+ assert len(result["records"]) == 1
58
+ assert result == expected
59
+
60
+
61
+ def test_simple_query_with_tooling(sf_instance):
62
+ """Ensure that a simple query returns the expected results."""
63
+ result = sf_instance.query(
64
+ "SELECT ProdSuffixType FROM OrgDomainLog LIMIT 1", tooling=True
65
+ )
66
+
67
+ sf_api_version = sf_instance.api_version
68
+ expected = {
69
+ "size": 1,
70
+ "totalSize": 1,
71
+ "done": True,
72
+ "queryLocator": None,
73
+ "entityTypeName": "OrgDomainLog",
74
+ "records": [
75
+ {
76
+ "attributes": {
77
+ "type": "OrgDomainLog",
78
+ "url": f"/services/data/{sf_api_version}/tooling/sobjects/OrgDomainLog/9UXaj000000p9inGAA",
79
+ },
80
+ "ProdSuffixType": "MySalesforce",
81
+ }
82
+ ],
83
+ }
84
+
85
+ assert result["done"]
86
+ assert result["totalSize"] == 1
87
+ assert len(result["records"]) == 1
88
+ assert result == expected
89
+
90
+
91
+ def test_query_with_pagination(sf_instance):
92
+ """Ensure that query pagination is functioning"""
93
+ current_count = sf_instance.query("SELECT Count() FROM FeedComment LIMIT 2200")[
94
+ "totalSize"
95
+ ]
96
+ if current_count < 2200:
97
+ feedItemId = sf_instance.query("SELECT Id FROM FeedItem LIMIT 1")["records"][0][
98
+ "Id"
99
+ ]
100
+ required_count = 2200 - current_count + 250
101
+ comments = [
102
+ {
103
+ "FeedItemId": feedItemId,
104
+ "CommentBody": f"Test comment {i} via {sf_instance.user_agent}",
105
+ }
106
+ for i in range(required_count)
107
+ ]
108
+
109
+ results = sf_instance.create("FeedComment", comments)
110
+ assert results and isinstance(results, list), (
111
+ f"Batch create did not return a list: {results}"
112
+ )
113
+
114
+ current_count = sf_instance.query("SELECT Count() FROM FeedComment LIMIT 2200")[
115
+ "totalSize"
116
+ ]
117
+
118
+ assert current_count >= 2200, (
119
+ "Not enough FeedComment records for pagination test exist, despite recent creation..."
120
+ )
121
+
122
+ result = sf_instance.query("SELECT Id FROM FeedComment LIMIT 2200")
123
+
124
+ assert len(result["records"]) == 2200
125
+ assert result["totalSize"] == 2200
126
+ assert result["done"]
@@ -3,5 +3,5 @@ requires-python = ">=3.9"
3
3
 
4
4
  [[package]]
5
5
  name = "sfq"
6
- version = "0.0.27"
6
+ version = "0.0.29"
7
7
  source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes