oneshot-python 0.8.3__tar.gz → 0.9.0__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.
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/.gitignore +2 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/PKG-INFO +1 -1
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/oneshot/client.py +121 -5
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/pyproject.toml +1 -1
- oneshot_python-0.9.0/tests/test_phones_pending.py +347 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/uv.lock +1 -1
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/README.md +0 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/oneshot/__init__.py +0 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/oneshot/_errors.py +0 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/oneshot/x402.py +0 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/tests/__init__.py +0 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/tests/test_balance.py +0 -0
- {oneshot_python-0.8.3 → oneshot_python-0.9.0}/tests/test_x402.py +0 -0
|
@@ -94,10 +94,25 @@ class OneShotClient:
|
|
|
94
94
|
*,
|
|
95
95
|
max_cost: Optional[float] = None,
|
|
96
96
|
timeout_sec: int = 120,
|
|
97
|
+
wait_for_phones: bool = False,
|
|
98
|
+
phone_timeout_sec: int = 360,
|
|
97
99
|
) -> Any:
|
|
98
|
-
"""Execute a paid tool call (blocking). Handles the full x402 flow.
|
|
100
|
+
"""Execute a paid tool call (blocking). Handles the full x402 flow.
|
|
101
|
+
|
|
102
|
+
Set ``wait_for_phones=True`` to keep polling AFTER status=completed
|
|
103
|
+
until Apollo's async phone-reveal webhook delivers phone numbers
|
|
104
|
+
(or until ``phone_timeout_sec`` expires, default 6 min — Apollo says
|
|
105
|
+
"several minutes"). Default is False for backwards compat.
|
|
106
|
+
"""
|
|
99
107
|
return asyncio.get_event_loop().run_until_complete(
|
|
100
|
-
self.acall_tool(
|
|
108
|
+
self.acall_tool(
|
|
109
|
+
endpoint,
|
|
110
|
+
payload,
|
|
111
|
+
max_cost=max_cost,
|
|
112
|
+
timeout_sec=timeout_sec,
|
|
113
|
+
wait_for_phones=wait_for_phones,
|
|
114
|
+
phone_timeout_sec=phone_timeout_sec,
|
|
115
|
+
)
|
|
101
116
|
)
|
|
102
117
|
|
|
103
118
|
async def acall_tool(
|
|
@@ -107,6 +122,8 @@ class OneShotClient:
|
|
|
107
122
|
*,
|
|
108
123
|
max_cost: Optional[float] = None,
|
|
109
124
|
timeout_sec: int = 120,
|
|
125
|
+
wait_for_phones: bool = False,
|
|
126
|
+
phone_timeout_sec: int = 360,
|
|
110
127
|
) -> Any:
|
|
111
128
|
"""Execute a paid tool call (async). Handles the full x402 flow."""
|
|
112
129
|
url = f"{self.base_url}{endpoint}"
|
|
@@ -137,7 +154,13 @@ class OneShotClient:
|
|
|
137
154
|
"pending",
|
|
138
155
|
"processing",
|
|
139
156
|
):
|
|
140
|
-
return await self._poll_job(
|
|
157
|
+
return await self._poll_job(
|
|
158
|
+
client,
|
|
159
|
+
result["request_id"],
|
|
160
|
+
timeout_sec,
|
|
161
|
+
wait_for_phones=wait_for_phones,
|
|
162
|
+
phone_timeout_sec=phone_timeout_sec,
|
|
163
|
+
)
|
|
141
164
|
return result.get("data", result)
|
|
142
165
|
|
|
143
166
|
# Step 2 — Parse 402 response
|
|
@@ -219,7 +242,13 @@ class OneShotClient:
|
|
|
219
242
|
"processing",
|
|
220
243
|
):
|
|
221
244
|
self._log(f"Job queued: {result['request_id']}")
|
|
222
|
-
return await self._poll_job(
|
|
245
|
+
return await self._poll_job(
|
|
246
|
+
client,
|
|
247
|
+
result["request_id"],
|
|
248
|
+
timeout_sec,
|
|
249
|
+
wait_for_phones=wait_for_phones,
|
|
250
|
+
phone_timeout_sec=phone_timeout_sec,
|
|
251
|
+
)
|
|
223
252
|
|
|
224
253
|
return result.get("data", result)
|
|
225
254
|
|
|
@@ -601,6 +630,9 @@ class OneShotClient:
|
|
|
601
630
|
client: httpx.AsyncClient,
|
|
602
631
|
request_id: str,
|
|
603
632
|
timeout_sec: int,
|
|
633
|
+
*,
|
|
634
|
+
wait_for_phones: bool = False,
|
|
635
|
+
phone_timeout_sec: int = 360,
|
|
604
636
|
) -> Any:
|
|
605
637
|
start = time.monotonic()
|
|
606
638
|
retries = 0
|
|
@@ -622,7 +654,20 @@ class OneShotClient:
|
|
|
622
654
|
|
|
623
655
|
if job.get("status") == "completed":
|
|
624
656
|
self._log("Job completed")
|
|
625
|
-
|
|
657
|
+
result = job.get("result", job)
|
|
658
|
+
# Optional second phase: keep polling for Apollo phone-reveal
|
|
659
|
+
# callbacks. Only fires when the caller explicitly opts in
|
|
660
|
+
# AND the result still has phones_pending=true (set by the
|
|
661
|
+
# worker when Apollo enrichment is awaiting an async webhook).
|
|
662
|
+
# Backwards-compat: existing callers see no change.
|
|
663
|
+
if wait_for_phones and self._is_phones_pending(result):
|
|
664
|
+
return await self._poll_for_phones(
|
|
665
|
+
client,
|
|
666
|
+
request_id,
|
|
667
|
+
phone_timeout_sec,
|
|
668
|
+
initial_result=result,
|
|
669
|
+
)
|
|
670
|
+
return result
|
|
626
671
|
|
|
627
672
|
if job.get("status") == "failed":
|
|
628
673
|
raise JobError(
|
|
@@ -644,3 +689,74 @@ class OneShotClient:
|
|
|
644
689
|
|
|
645
690
|
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
646
691
|
raise JobTimeoutError(request_id, elapsed_ms)
|
|
692
|
+
|
|
693
|
+
@staticmethod
|
|
694
|
+
def _is_phones_pending(result: Any) -> bool:
|
|
695
|
+
"""Detects whether a result is awaiting Apollo's async phone-reveal
|
|
696
|
+
webhook. Tolerates two shapes: the unwrapped result ``{phones_pending}``
|
|
697
|
+
and the deep_research_person wrapper ``{result: {phones_pending}}``.
|
|
698
|
+
|
|
699
|
+
Mirrors the TS SDK's _isPhonesPending — kept in lockstep so the worker
|
|
700
|
+
contract works for both SDKs.
|
|
701
|
+
"""
|
|
702
|
+
if not isinstance(result, dict):
|
|
703
|
+
return False
|
|
704
|
+
if result.get("phones_pending") is True:
|
|
705
|
+
return True
|
|
706
|
+
inner = result.get("result")
|
|
707
|
+
if isinstance(inner, dict) and inner.get("phones_pending") is True:
|
|
708
|
+
return True
|
|
709
|
+
return False
|
|
710
|
+
|
|
711
|
+
async def _poll_for_phones(
|
|
712
|
+
self,
|
|
713
|
+
client: httpx.AsyncClient,
|
|
714
|
+
request_id: str,
|
|
715
|
+
timeout_sec: int,
|
|
716
|
+
*,
|
|
717
|
+
initial_result: Any = None,
|
|
718
|
+
) -> Any:
|
|
719
|
+
"""Slow poll loop (5s cadence) for Apollo phone-reveal callbacks.
|
|
720
|
+
|
|
721
|
+
The webhook handler in soul-hunt-api UPDATEs jobs.result_data with
|
|
722
|
+
phones once Apollo POSTs them. We poll the GET endpoint until phones
|
|
723
|
+
arrive (phones_pending flips false) or ``timeout_sec`` expires. On
|
|
724
|
+
timeout we return the last snapshot — phones_pending will still be
|
|
725
|
+
True so the consumer knows phones never arrived.
|
|
726
|
+
|
|
727
|
+
Soft-fails on transient HTTP errors and returns the most recent
|
|
728
|
+
successful snapshot to avoid losing the sync result we already have.
|
|
729
|
+
"""
|
|
730
|
+
deadline = time.monotonic() + timeout_sec
|
|
731
|
+
interval = 5.0
|
|
732
|
+
last_result: Any = initial_result
|
|
733
|
+
|
|
734
|
+
while time.monotonic() < deadline:
|
|
735
|
+
try:
|
|
736
|
+
resp = await client.get(
|
|
737
|
+
f"{self.base_url}/v1/requests/{request_id}",
|
|
738
|
+
headers=self._headers(),
|
|
739
|
+
)
|
|
740
|
+
if not resp.is_success:
|
|
741
|
+
if last_result is not None:
|
|
742
|
+
return last_result
|
|
743
|
+
raise ToolError(
|
|
744
|
+
"Failed to check job status",
|
|
745
|
+
resp.status_code,
|
|
746
|
+
resp.text,
|
|
747
|
+
)
|
|
748
|
+
job = resp.json()
|
|
749
|
+
last_result = job.get("result", job)
|
|
750
|
+
if not self._is_phones_pending(last_result):
|
|
751
|
+
return last_result
|
|
752
|
+
except (OneShotError, JobError):
|
|
753
|
+
raise
|
|
754
|
+
except Exception:
|
|
755
|
+
if last_result is not None:
|
|
756
|
+
return last_result
|
|
757
|
+
raise
|
|
758
|
+
await asyncio.sleep(interval)
|
|
759
|
+
|
|
760
|
+
# Timeout — return the last snapshot. phones_pending is still True
|
|
761
|
+
# so the caller knows phones never arrived.
|
|
762
|
+
return last_result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Tests for the Apollo phone-reveal polling extension on OneShotClient.
|
|
2
|
+
|
|
3
|
+
Apollo's phone numbers arrive asynchronously via a webhook minutes after the
|
|
4
|
+
initial enrichment. The worker writes ``result.phones_pending = True`` so SDK
|
|
5
|
+
clients can opt into a longer poll (``wait_for_phones=True``) until phones
|
|
6
|
+
land. These tests verify:
|
|
7
|
+
|
|
8
|
+
1. The static ``_is_phones_pending`` shape detector
|
|
9
|
+
2. The ``_poll_for_phones`` async loop with mocked httpx responses
|
|
10
|
+
3. The ``_poll_job → _poll_for_phones`` decision branch (opt-in vs default)
|
|
11
|
+
|
|
12
|
+
Run with:
|
|
13
|
+
cd packages/oneshot-python && uv run pytest tests/test_phones_pending.py -v
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from typing import Any
|
|
18
|
+
from unittest.mock import MagicMock
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from oneshot.client import OneShotClient
|
|
23
|
+
|
|
24
|
+
# Deterministic test key (same one used by test_x402.py — never use with funds)
|
|
25
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
26
|
+
TEST_BASE_URL = "http://test.local"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_client() -> OneShotClient:
|
|
30
|
+
"""Real OneShotClient with a stable test key. No network calls in tests
|
|
31
|
+
because we mock the httpx layer entirely."""
|
|
32
|
+
return OneShotClient(
|
|
33
|
+
private_key=TEST_PRIVATE_KEY,
|
|
34
|
+
base_url=TEST_BASE_URL,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Mock httpx.AsyncClient ──────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _make_mock_response(status_code: int = 200, json_data: Any = None, text: str = "") -> MagicMock:
|
|
42
|
+
resp = MagicMock()
|
|
43
|
+
resp.is_success = 200 <= status_code < 300
|
|
44
|
+
resp.status_code = status_code
|
|
45
|
+
resp.json = MagicMock(return_value=json_data or {})
|
|
46
|
+
resp.text = text
|
|
47
|
+
return resp
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _MockHttpxClient:
|
|
51
|
+
"""Scriptable httpx.AsyncClient stand-in. Tests load `responses` with the
|
|
52
|
+
sequence of mock responses (or exceptions) and the get() method pops them.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, responses):
|
|
56
|
+
self.responses = list(responses)
|
|
57
|
+
self.calls = []
|
|
58
|
+
|
|
59
|
+
async def get(self, url, headers=None):
|
|
60
|
+
self.calls.append({"url": url, "headers": headers})
|
|
61
|
+
if not self.responses:
|
|
62
|
+
raise RuntimeError(f"Unexpected get to {url} (queue empty, {len(self.calls)} total calls)")
|
|
63
|
+
next_resp = self.responses.pop(0)
|
|
64
|
+
if isinstance(next_resp, Exception):
|
|
65
|
+
raise next_resp
|
|
66
|
+
return next_resp
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
# _is_phones_pending shape detection
|
|
71
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestIsPhonesPending:
|
|
75
|
+
"""Locks the contract between the worker's result_data shape and the SDK.
|
|
76
|
+
If either side changes, these tests fail."""
|
|
77
|
+
|
|
78
|
+
detect = staticmethod(OneShotClient._is_phones_pending)
|
|
79
|
+
|
|
80
|
+
def test_false_for_non_dict(self):
|
|
81
|
+
assert self.detect(None) is False
|
|
82
|
+
assert self.detect("string") is False
|
|
83
|
+
assert self.detect(42) is False
|
|
84
|
+
assert self.detect([]) is False
|
|
85
|
+
assert self.detect(()) is False
|
|
86
|
+
|
|
87
|
+
def test_false_for_empty_dict(self):
|
|
88
|
+
assert self.detect({}) is False
|
|
89
|
+
|
|
90
|
+
def test_true_for_top_level_phones_pending(self):
|
|
91
|
+
# Shape used by enrich/profile, find_email — flatter result
|
|
92
|
+
assert self.detect({"full_name": "Jane", "phones_pending": True}) is True
|
|
93
|
+
|
|
94
|
+
def test_true_for_nested_result_phones_pending(self):
|
|
95
|
+
# Shape used by deep_research_person — wraps the person dict
|
|
96
|
+
assert self.detect({
|
|
97
|
+
"status": "completed",
|
|
98
|
+
"result": {"full_name": "Jane", "phones_pending": True},
|
|
99
|
+
}) is True
|
|
100
|
+
|
|
101
|
+
def test_false_when_explicitly_false(self):
|
|
102
|
+
assert self.detect({"phones_pending": False}) is False
|
|
103
|
+
assert self.detect({"result": {"phones_pending": False}}) is False
|
|
104
|
+
|
|
105
|
+
def test_false_when_missing(self):
|
|
106
|
+
# Non-Apollo providers never set phones_pending
|
|
107
|
+
assert self.detect({"full_name": "Jane", "phones": ["+1"]}) is False
|
|
108
|
+
assert self.detect({"result": {"full_name": "Jane"}}) is False
|
|
109
|
+
|
|
110
|
+
def test_strict_equality_only(self):
|
|
111
|
+
# Defensive: only literal True triggers waiting. A 'pending' string
|
|
112
|
+
# or a 1 won't accidentally trap a client in an infinite poll.
|
|
113
|
+
assert self.detect({"phones_pending": "pending"}) is False
|
|
114
|
+
assert self.detect({"phones_pending": 1}) is False
|
|
115
|
+
assert self.detect({"phones_pending": "true"}) is False
|
|
116
|
+
|
|
117
|
+
def test_does_not_scan_deeper_than_result(self):
|
|
118
|
+
# result.enrichment.phones_pending is intentionally NOT detected
|
|
119
|
+
assert self.detect({"result": {"enrichment": {"phones_pending": True}}}) is False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
123
|
+
# _poll_for_phones polling loop
|
|
124
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestPollForPhones:
|
|
128
|
+
"""Exercises _poll_for_phones directly with a scripted mock httpx client.
|
|
129
|
+
Patches asyncio.sleep so multi-poll tests don't take 15+ seconds of real
|
|
130
|
+
wall time."""
|
|
131
|
+
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_returns_immediately_when_phones_pending_false(self):
|
|
134
|
+
client = _MockHttpxClient([
|
|
135
|
+
_make_mock_response(200, {
|
|
136
|
+
"request_id": "req-1",
|
|
137
|
+
"status": "completed",
|
|
138
|
+
"result": {"result": {"phones": ["+15551234567"], "phones_pending": False}},
|
|
139
|
+
}),
|
|
140
|
+
])
|
|
141
|
+
sdk = make_client()
|
|
142
|
+
out = await sdk._poll_for_phones(client, "req-1", 60)
|
|
143
|
+
assert out["result"]["phones"] == ["+15551234567"]
|
|
144
|
+
assert out["result"]["phones_pending"] is False
|
|
145
|
+
assert len(client.calls) == 1
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_polls_until_phones_arrive(self, monkeypatch):
|
|
149
|
+
async def fast_sleep(_):
|
|
150
|
+
return None
|
|
151
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
152
|
+
|
|
153
|
+
client = _MockHttpxClient([
|
|
154
|
+
_make_mock_response(200, {"result": {"result": {"phones_pending": True}}}),
|
|
155
|
+
_make_mock_response(200, {"result": {"result": {"phones_pending": True}}}),
|
|
156
|
+
_make_mock_response(200, {
|
|
157
|
+
"result": {"result": {"phones": ["+15559999999"], "phones_pending": False}},
|
|
158
|
+
}),
|
|
159
|
+
])
|
|
160
|
+
sdk = make_client()
|
|
161
|
+
out = await sdk._poll_for_phones(client, "req-1", 60)
|
|
162
|
+
assert len(client.calls) == 3
|
|
163
|
+
assert out["result"]["phones"] == ["+15559999999"]
|
|
164
|
+
|
|
165
|
+
@pytest.mark.asyncio
|
|
166
|
+
async def test_returns_initial_result_on_first_fetch_failure(self, monkeypatch):
|
|
167
|
+
async def fast_sleep(_):
|
|
168
|
+
return None
|
|
169
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
170
|
+
|
|
171
|
+
client = _MockHttpxClient([RuntimeError("network down")])
|
|
172
|
+
sdk = make_client()
|
|
173
|
+
initial = {"result": {"full_name": "Jane", "phones_pending": True, "apollo_person_id": "abc"}}
|
|
174
|
+
out = await sdk._poll_for_phones(client, "req-1", 60, initial_result=initial)
|
|
175
|
+
# Soft-failed on first fetch, returned the snapshot we passed in
|
|
176
|
+
assert out is initial
|
|
177
|
+
assert len(client.calls) == 1
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_returns_last_snapshot_on_transient_failure_after_success(self, monkeypatch):
|
|
181
|
+
async def fast_sleep(_):
|
|
182
|
+
return None
|
|
183
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
184
|
+
|
|
185
|
+
client = _MockHttpxClient([
|
|
186
|
+
_make_mock_response(200, {
|
|
187
|
+
"result": {"result": {"full_name": "Jane", "phones_pending": True}},
|
|
188
|
+
}),
|
|
189
|
+
RuntimeError("transient blip"),
|
|
190
|
+
])
|
|
191
|
+
sdk = make_client()
|
|
192
|
+
out = await sdk._poll_for_phones(client, "req-1", 60)
|
|
193
|
+
assert out["result"]["full_name"] == "Jane"
|
|
194
|
+
assert out["result"]["phones_pending"] is True
|
|
195
|
+
|
|
196
|
+
@pytest.mark.asyncio
|
|
197
|
+
async def test_returns_last_snapshot_on_http_500_after_success(self, monkeypatch):
|
|
198
|
+
async def fast_sleep(_):
|
|
199
|
+
return None
|
|
200
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
201
|
+
|
|
202
|
+
client = _MockHttpxClient([
|
|
203
|
+
_make_mock_response(200, {
|
|
204
|
+
"result": {"result": {"phones_pending": True, "full_name": "Jane"}},
|
|
205
|
+
}),
|
|
206
|
+
_make_mock_response(500, text="internal error"),
|
|
207
|
+
])
|
|
208
|
+
sdk = make_client()
|
|
209
|
+
out = await sdk._poll_for_phones(client, "req-1", 60)
|
|
210
|
+
assert out["result"]["full_name"] == "Jane"
|
|
211
|
+
assert out["result"]["phones_pending"] is True
|
|
212
|
+
|
|
213
|
+
@pytest.mark.asyncio
|
|
214
|
+
async def test_raises_when_first_fetch_fails_and_no_initial(self, monkeypatch):
|
|
215
|
+
async def fast_sleep(_):
|
|
216
|
+
return None
|
|
217
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
218
|
+
|
|
219
|
+
client = _MockHttpxClient([RuntimeError("network down")])
|
|
220
|
+
sdk = make_client()
|
|
221
|
+
with pytest.raises(RuntimeError, match="network down"):
|
|
222
|
+
await sdk._poll_for_phones(client, "req-1", 60)
|
|
223
|
+
|
|
224
|
+
@pytest.mark.asyncio
|
|
225
|
+
async def test_returns_last_snapshot_on_timeout(self, monkeypatch):
|
|
226
|
+
# Tight 0.05s timeout — first poll completes, then deadline fires
|
|
227
|
+
async def fast_sleep(_):
|
|
228
|
+
return None
|
|
229
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
230
|
+
|
|
231
|
+
client = _MockHttpxClient([
|
|
232
|
+
_make_mock_response(200, {
|
|
233
|
+
"result": {"result": {"phones_pending": True, "full_name": "Jane"}},
|
|
234
|
+
}),
|
|
235
|
+
] * 10) # plenty of responses, but timeout will end the loop
|
|
236
|
+
sdk = make_client()
|
|
237
|
+
out = await sdk._poll_for_phones(client, "req-1", 0.05)
|
|
238
|
+
assert out["result"]["phones_pending"] is True
|
|
239
|
+
assert out["result"]["full_name"] == "Jane"
|
|
240
|
+
|
|
241
|
+
@pytest.mark.asyncio
|
|
242
|
+
async def test_hits_correct_url_and_headers(self):
|
|
243
|
+
client = _MockHttpxClient([
|
|
244
|
+
_make_mock_response(200, {
|
|
245
|
+
"result": {"result": {"phones_pending": False, "phones": ["+15550000000"]}},
|
|
246
|
+
}),
|
|
247
|
+
])
|
|
248
|
+
sdk = make_client()
|
|
249
|
+
await sdk._poll_for_phones(client, "req-1", 60)
|
|
250
|
+
assert client.calls[0]["url"] == f"{TEST_BASE_URL}/v1/requests/req-1"
|
|
251
|
+
# Real client sets X-Agent-ID to the wallet address derived from the test key
|
|
252
|
+
assert "X-Agent-ID" in client.calls[0]["headers"]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
256
|
+
# _poll_job → _poll_for_phones decision branch
|
|
257
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TestPollJobWaitForPhonesDecision:
|
|
261
|
+
"""Verifies that _poll_job opts into _poll_for_phones only when both
|
|
262
|
+
wait_for_phones=True AND result.phones_pending=True. All other combinations
|
|
263
|
+
must return at status=completed without extension polling."""
|
|
264
|
+
|
|
265
|
+
@pytest.mark.asyncio
|
|
266
|
+
async def test_default_returns_at_completed_even_if_phones_pending(self, monkeypatch):
|
|
267
|
+
async def fast_sleep(_):
|
|
268
|
+
return None
|
|
269
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
270
|
+
|
|
271
|
+
client = _MockHttpxClient([
|
|
272
|
+
_make_mock_response(200, {
|
|
273
|
+
"request_id": "req-1",
|
|
274
|
+
"status": "completed",
|
|
275
|
+
"result": {"result": {"full_name": "Jane", "phones_pending": True}},
|
|
276
|
+
}),
|
|
277
|
+
])
|
|
278
|
+
sdk = make_client()
|
|
279
|
+
# wait_for_phones default = False
|
|
280
|
+
out = await sdk._poll_job(client, "req-1", 60)
|
|
281
|
+
# Returned even though phones_pending=true — caller didn't opt in
|
|
282
|
+
assert out["result"]["phones_pending"] is True
|
|
283
|
+
assert out["result"]["full_name"] == "Jane"
|
|
284
|
+
assert len(client.calls) == 1 # no extension polling
|
|
285
|
+
|
|
286
|
+
@pytest.mark.asyncio
|
|
287
|
+
async def test_wait_for_phones_triggers_extension_polling(self, monkeypatch):
|
|
288
|
+
async def fast_sleep(_):
|
|
289
|
+
return None
|
|
290
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
291
|
+
|
|
292
|
+
client = _MockHttpxClient([
|
|
293
|
+
# First poll: status=completed but phones_pending=true
|
|
294
|
+
_make_mock_response(200, {
|
|
295
|
+
"request_id": "req-1",
|
|
296
|
+
"status": "completed",
|
|
297
|
+
"result": {"result": {"phones_pending": True, "apollo_person_id": "abc"}},
|
|
298
|
+
}),
|
|
299
|
+
# _poll_for_phones first poll: phones arrived
|
|
300
|
+
_make_mock_response(200, {
|
|
301
|
+
"request_id": "req-1",
|
|
302
|
+
"status": "completed",
|
|
303
|
+
"result": {"result": {"phones": ["+15551234567"], "phones_pending": False}},
|
|
304
|
+
}),
|
|
305
|
+
])
|
|
306
|
+
sdk = make_client()
|
|
307
|
+
out = await sdk._poll_job(client, "req-1", 60, wait_for_phones=True)
|
|
308
|
+
assert out["result"]["phones"] == ["+15551234567"]
|
|
309
|
+
assert out["result"]["phones_pending"] is False
|
|
310
|
+
assert len(client.calls) == 2
|
|
311
|
+
|
|
312
|
+
@pytest.mark.asyncio
|
|
313
|
+
async def test_wait_for_phones_no_op_when_phones_already_present(self, monkeypatch):
|
|
314
|
+
async def fast_sleep(_):
|
|
315
|
+
return None
|
|
316
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
317
|
+
|
|
318
|
+
client = _MockHttpxClient([
|
|
319
|
+
_make_mock_response(200, {
|
|
320
|
+
"request_id": "req-1",
|
|
321
|
+
"status": "completed",
|
|
322
|
+
"result": {"result": {"phones": ["+15551234567"], "phones_pending": False}},
|
|
323
|
+
}),
|
|
324
|
+
])
|
|
325
|
+
sdk = make_client()
|
|
326
|
+
out = await sdk._poll_job(client, "req-1", 60, wait_for_phones=True)
|
|
327
|
+
# No extension polls — _poll_for_phones never invoked
|
|
328
|
+
assert out["result"]["phones"] == ["+15551234567"]
|
|
329
|
+
assert len(client.calls) == 1
|
|
330
|
+
|
|
331
|
+
@pytest.mark.asyncio
|
|
332
|
+
async def test_wait_for_phones_no_op_for_non_apollo_results(self, monkeypatch):
|
|
333
|
+
async def fast_sleep(_):
|
|
334
|
+
return None
|
|
335
|
+
monkeypatch.setattr(asyncio, "sleep", fast_sleep)
|
|
336
|
+
|
|
337
|
+
client = _MockHttpxClient([
|
|
338
|
+
_make_mock_response(200, {
|
|
339
|
+
"request_id": "req-1",
|
|
340
|
+
"status": "completed",
|
|
341
|
+
"result": {"result": {"full_name": "Jane", "provider": "prospeo"}},
|
|
342
|
+
}),
|
|
343
|
+
])
|
|
344
|
+
sdk = make_client()
|
|
345
|
+
out = await sdk._poll_job(client, "req-1", 60, wait_for_phones=True)
|
|
346
|
+
assert out["result"]["full_name"] == "Jane"
|
|
347
|
+
assert len(client.calls) == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|