sfq 0.0.27__tar.gz → 0.0.28__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.
- {sfq-0.0.27 → sfq-0.0.28}/PKG-INFO +1 -1
- {sfq-0.0.27 → sfq-0.0.28}/pyproject.toml +1 -1
- {sfq-0.0.27 → sfq-0.0.28}/src/sfq/__init__.py +203 -19
- sfq-0.0.28/tests/test_cdelete.py +73 -0
- sfq-0.0.28/tests/test_cquery.py +66 -0
- sfq-0.0.28/tests/test_create.py +80 -0
- {sfq-0.0.27 → sfq-0.0.28}/tests/test_log_trace_redact.py +53 -0
- sfq-0.0.28/tests/test_query.py +126 -0
- {sfq-0.0.27 → sfq-0.0.28}/uv.lock +1 -1
- {sfq-0.0.27 → sfq-0.0.28}/.github/workflows/publish.yml +0 -0
- {sfq-0.0.27 → sfq-0.0.28}/.gitignore +0 -0
- {sfq-0.0.27 → sfq-0.0.28}/.python-version +0 -0
- {sfq-0.0.27 → sfq-0.0.28}/README.md +0 -0
- {sfq-0.0.27 → sfq-0.0.28}/src/sfq/_cometd.py +0 -0
- {sfq-0.0.27 → sfq-0.0.28}/src/sfq/py.typed +0 -0
- {sfq-0.0.27 → sfq-0.0.28}/tests/test_limits_api.py +0 -0
@@ -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,
|
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
|
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.
|
102
|
+
user_agent: str = "sfq/0.0.28",
|
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(
|
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(
|
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(
|
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
|
-
|
939
|
-
|
940
|
-
|
941
|
-
|
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 = [
|
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}"
|
@@ -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"]
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|