Skip to content

Commit c7ed39c

Browse files
✨(ingestion) create a shared Talensoft front client (#599)
* ✨(ingestion) create a shared Talensoft front client * ♻️(ingestion) revert changes to async client * ✅(ingestion) test requests share the same token * ✅(ingestion) test token is fetched once with different webhooks
1 parent 3925f5b commit c7ed39c

5 files changed

Lines changed: 122 additions & 16 deletions

File tree

src/ingestion/api/routes.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
2-
from collections.abc import AsyncGenerator
2+
from typing import Callable, TypeVar
33

44
from dependency_injector.wiring import Provide, inject
55
from fastapi import APIRouter, Depends, HTTPException, Query, Request
66
from pydantic import ValidationError
7+
from starlette.datastructures import State
78

89
from api.config import Settings, get_settings
910
from api.talentsoft import verify_talentsoft_signature
@@ -27,24 +28,44 @@
2728

2829
_OK = {"status": "ok"}
2930

31+
_T = TypeVar("_T")
3032

31-
async def get_load_offer_details_use_case(
33+
34+
def _state_setdefault(state: State, key: str, factory: Callable[[], _T]) -> _T:
35+
if not hasattr(state, key):
36+
setattr(state, key, factory())
37+
return getattr(state, key)
38+
39+
40+
def get_load_offer_details_use_case(
41+
request: Request,
3242
settings: Settings = Depends(get_settings),
33-
) -> AsyncGenerator[LoadOfferDetailsUseCase | None, None]:
43+
) -> LoadOfferDetailsUseCase | None:
3444
if (
3545
not settings.talentsoft_front_client_id
3646
or not settings.talentsoft_front_client_secret
3747
or not settings.talentsoft_front_base_url
3848
):
39-
yield None
40-
else:
49+
return None
50+
51+
base_url = settings.talentsoft_front_base_url
52+
client_id = settings.talentsoft_front_client_id
53+
client_secret = settings.talentsoft_front_client_secret
54+
55+
def _make_client() -> TalentsoftFrontClient:
4156
config = TalentsoftConfig(
42-
base_url=settings.talentsoft_front_base_url,
43-
client_id=settings.talentsoft_front_client_id,
44-
client_secret=settings.talentsoft_front_client_secret,
57+
base_url=base_url,
58+
client_id=client_id,
59+
client_secret=client_secret,
4560
)
46-
async with TalentsoftFrontClient(config=config, logger=logger) as client:
47-
yield LoadOfferDetailsUseCase(talentsoft_client=client)
61+
return TalentsoftFrontClient(config=config, logger=logger)
62+
63+
client = _state_setdefault(
64+
request.app.state,
65+
"talentsoft_front_client",
66+
_make_client,
67+
)
68+
return LoadOfferDetailsUseCase(talentsoft_client=client)
4869

4970

5071
@public_router.get("/health")

src/ingestion/infrastructure/gateways/async_http_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
class AsyncHttpClient:
77
def __init__(self, timeout: int = 120):
88
self.timeout = timeout
9-
self._client: Optional[httpx.AsyncClient] = None
9+
self._client: Optional[httpx.AsyncClient] = httpx.AsyncClient(timeout=timeout)
1010

1111
async def __aenter__(self) -> Self:
1212
self._client = httpx.AsyncClient(timeout=self.timeout)

src/ingestion/tests/presentation/test_webhook_load_offer.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,39 @@ async def test_other_event_type_does_not_call_talentsoft(
162162

163163
assert response.status_code == 200
164164
assert httpx_mock.get_requests() == []
165+
166+
167+
@pytest.mark.asyncio
168+
async def test_token_is_fetched_once_across_two_webhook_requests(
169+
talentsoft_client, httpx_mock: HTTPXMock
170+
):
171+
reference_2 = "2024-VACANCY-002"
172+
173+
_mock_token_response(httpx_mock)
174+
httpx_mock.add_response(
175+
method="GET",
176+
url=f"{DETAIL_OFFER_URL}?reference={REFERENCE}",
177+
json=_detail_offer_payload(REFERENCE),
178+
)
179+
httpx_mock.add_response(
180+
method="GET",
181+
url=f"{DETAIL_OFFER_URL}?reference={reference_2}",
182+
json=_detail_offer_payload(reference_2),
183+
)
184+
185+
response_1 = make_signed_request(
186+
talentsoft_client, {"event_type": "vacancy_new", "reference": REFERENCE}
187+
)
188+
response_2 = make_signed_request(
189+
talentsoft_client, {"event_type": "vacancy_new", "reference": reference_2}
190+
)
191+
192+
assert response_1.status_code == 200
193+
assert response_2.status_code == 200
194+
195+
requests = httpx_mock.get_requests()
196+
# 1 token request + 2 detail requests.
197+
# token is not fetched again on the second webhook call
198+
assert len(requests) == 3
199+
token_requests = [r for r in requests if str(r.url) == TOKEN_URL]
200+
assert len(token_requests) == 1

src/ingestion/tests/unit/external_gateways/test_talentsoft_client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,44 @@ async def test_get_detail_fails_due_to_unauthorized_token(self, talentsoft_clien
196196
assert mock_get.call_count == 2
197197
mock_post.assert_called_once() # Token refresh called
198198

199+
@pytest.mark.asyncio
200+
async def test_token_is_fetched_once_when_client_is_shared_across_requests(
201+
self, talentsoft_client
202+
):
203+
response_data_1 = detail_offer_response()
204+
response_data_2 = detail_offer_response()
205+
token_response = {
206+
"access_token": fake.uuid4(),
207+
"token_type": fake.word(),
208+
"expires_in": 3600,
209+
}
210+
211+
talentsoft_client.cached_token = None
212+
213+
with (
214+
patch.object(talentsoft_client, "get", new_callable=AsyncMock) as mock_get,
215+
patch.object(
216+
talentsoft_client, "post", new_callable=AsyncMock
217+
) as mock_post,
218+
):
219+
mock_get.side_effect = [
220+
mocked_response(return_value=response_data_1),
221+
mocked_response(return_value=response_data_2),
222+
]
223+
mock_post.return_value = mocked_response(return_value=token_response)
224+
225+
offer_1 = await talentsoft_client.get_detail(
226+
reference=response_data_1["reference"]
227+
)
228+
offer_2 = await talentsoft_client.get_detail(
229+
reference=response_data_2["reference"]
230+
)
231+
232+
assert offer_1.reference == response_data_1["reference"]
233+
assert offer_2.reference == response_data_2["reference"]
234+
mock_post.assert_called_once() # Token was fetched only once, not per request
235+
assert mock_get.call_count == 2
236+
199237
@pytest.mark.asyncio
200238
async def test_get_detail_fails_after_max_retries_attempts(self, talentsoft_client):
201239
failed_response = mocked_response(status_code=500)
Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
import pytest
2+
from pytest_httpx import HTTPXMock
23

34
from infrastructure.gateways.async_http_client import AsyncHttpClient
45

56

67
@pytest.mark.asyncio
7-
async def test_post_without_context_manager_raises():
8+
async def test_post_without_context_manager_succeeds(httpx_mock: HTTPXMock):
9+
httpx_mock.add_response(method="POST", url="https://example.com")
810
client = AsyncHttpClient()
9-
with pytest.raises(RuntimeError, match="Client not initialized"):
10-
await client.post("https://example.com")
11+
response = await client.post("https://example.com")
12+
assert response.status_code == 200
1113

1214

1315
@pytest.mark.asyncio
14-
async def test_get_without_context_manager_raises():
16+
async def test_get_without_context_manager_succeeds(httpx_mock: HTTPXMock):
17+
httpx_mock.add_response(method="GET", url="https://example.com")
1518
client = AsyncHttpClient()
16-
with pytest.raises(RuntimeError, match="Client not initialized"):
19+
response = await client.get("https://example.com")
20+
assert response.status_code == 200
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_aclose_cleans_up_client(httpx_mock: HTTPXMock):
25+
httpx_mock.add_response(method="GET", url="https://example.com")
26+
async with AsyncHttpClient() as client:
1727
await client.get("https://example.com")
28+
assert client._client is None

0 commit comments

Comments
 (0)