Skip to content

Commit b8a3724

Browse files
committed
feat: add job description length validation and corresponding tests for resume score service
- fix tests base service to mock httpx tranport instead of rely on external services
1 parent 731da9e commit b8a3724

4 files changed

Lines changed: 162 additions & 9 deletions

File tree

magicalapi/services/resume_score_service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ async def get_resume_score(
3535
ErrorResponse: When an error occurs (e.g., 403 if webhook domain not whitelisted).
3636
3737
"""
38+
if not 100 <= len(job_description) <= 5000:
39+
raise ValueError("job_description must be between 100 and 5000 characters long")
40+
3841
request_body = {
3942
"url": url,
4043
"job_description": job_description,

tests/services/test_base_service.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@
1313
from magicalapi.types.schemas import HttpResponse
1414

1515

16+
def _make_mock_transport(*, timeout_on_delay: bool = False) -> httpx.MockTransport:
17+
def handler(request: httpx.Request) -> httpx.Response:
18+
if timeout_on_delay and request.url.path.endswith("/delay/10"):
19+
raise httpx.TimeoutException("request timed out", request=request)
20+
21+
if request.method == "POST":
22+
body = json.loads(request.content.decode()) if request.content else {}
23+
return httpx.Response(200, json={"json": body}, request=request)
24+
25+
if request.method == "GET":
26+
return httpx.Response(200, json={"url": str(request.url)}, request=request)
27+
28+
return httpx.Response(405, json={"message": "method not allowed"}, request=request)
29+
30+
return httpx.MockTransport(handler)
31+
32+
1633
@pytest_asyncio.fixture(scope="function")
1734
async def httpxclient(request) -> AsyncGenerator[httpx.AsyncClient]:
1835
timeout = (
@@ -25,6 +42,7 @@ async def httpxclient(request) -> AsyncGenerator[httpx.AsyncClient]:
2542
client = httpx.AsyncClient(
2643
headers={"content-type": "application/json"},
2744
timeout=timeout,
45+
transport=_make_mock_transport(timeout_on_delay=timeout is not None),
2846
)
2947

3048
yield client
@@ -39,7 +57,7 @@ async def test_base_service_post_request(httpxclient: httpx.AsyncClient):
3957
test_data = {"foo": "bar"}
4058

4159
response = await base_service._send_post_request(
42-
path="https://httpbin.org/post", data=test_data
60+
path="https://example.com/post", data=test_data
4361
)
4462
# check response type
4563
assert isinstance(response, HttpResponse)
@@ -52,7 +70,7 @@ async def test_base_service_post_request(httpxclient: httpx.AsyncClient):
5270
@pytest.mark.asyncio
5371
async def test_base_service_get_request(httpxclient: httpx.AsyncClient):
5472
base_service = BaseService(httpxclient)
55-
response = await base_service._send_get_request(path="https://httpbin.org/get")
73+
response = await base_service._send_get_request(path="https://example.com/get")
5674

5775
# check response type
5876
assert isinstance(response, HttpResponse)
@@ -69,11 +87,11 @@ async def test_base_service_request_timed_out(httpxclient: httpx.AsyncClient):
6987
# post request
7088
with pytest.raises(APIServerTimedout):
7189
await base_service._send_post_request(
72-
path="https://httpbin.org/delay/10", data={}
90+
path="https://example.com/delay/10", data={}
7391
)
7492
# gee request
7593
with pytest.raises(APIServerTimedout):
76-
await base_service._send_get_request(path="https://httpbin.org/delay/10")
94+
await base_service._send_get_request(path="https://example.com/delay/10")
7795

7896

7997
def test_base_service_validating_response(httpxclient: httpx.AsyncClient):
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from collections.abc import AsyncGenerator
2+
import json
3+
4+
import httpx
5+
import pytest
6+
import pytest_asyncio
7+
8+
from magicalapi.services.resume_score_service import ResumeScoreService
9+
from magicalapi.types.base import ErrorResponse
10+
from magicalapi.types.resume_score import ResumeScoreResponse
11+
from magicalapi.types.schemas import HttpResponse
12+
13+
14+
def _make_resume_score_response() -> HttpResponse:
15+
response_body = {
16+
"data": {
17+
"score": 87,
18+
"summary": "Strong match for the role",
19+
"strengths": [{"text": "Python experience", "category": "skills"}],
20+
"improvements": [
21+
{"text": "Add more leadership examples", "category": "other"}
22+
],
23+
"job_match": {
24+
"score": 90,
25+
"summary": "Job match is strong",
26+
"pros": [{"type": "title", "message": "Title aligns"}],
27+
"cons": [
28+
{"type": "gap", "message": "Missing one requirement"}
29+
],
30+
"warns": [
31+
{
32+
"type": "note",
33+
"message": "Consider tailoring summary",
34+
}
35+
],
36+
},
37+
"skill_match": {
38+
"score": 85,
39+
"summary": "Skills align",
40+
"skills": {"all_skills": ["Python", "AWS"]},
41+
"match_result": {
42+
"miss_match": ["Kubernetes"],
43+
"partial_match": ["Docker"],
44+
"strong_match": ["Python"],
45+
},
46+
},
47+
"education_match": {
48+
"score": 80,
49+
"summary": "Education is a fit",
50+
"pros": [{"type": "degree", "message": "Relevant degree"}],
51+
"cons": [
52+
{
53+
"type": "specialization",
54+
"message": "Could be more specific",
55+
}
56+
],
57+
"warns": [{"type": "note", "message": "No issues"}],
58+
},
59+
"more_information": {
60+
"region": "Europe",
61+
"overqualification_status": False,
62+
"qualification_reason": "Meets the role requirements",
63+
},
64+
"jd_text": "job description text",
65+
},
66+
"usage": {"credits": 10},
67+
}
68+
69+
return HttpResponse(
70+
text=json.dumps(response_body),
71+
status_code=200,
72+
)
73+
74+
75+
@pytest_asyncio.fixture(scope="function")
76+
async def httpxclient() -> AsyncGenerator[httpx.AsyncClient]:
77+
client = httpx.AsyncClient(headers={"content-type": "application/json"})
78+
79+
yield client
80+
81+
await client.aclose()
82+
del client
83+
84+
85+
@pytest.mark.asyncio
86+
@pytest.mark.parametrize("job_description", ["a" * 99, "a" * 5001])
87+
async def test_get_resume_score_rejects_invalid_job_description_length(
88+
httpxclient: httpx.AsyncClient, job_description: str
89+
):
90+
service = ResumeScoreService(httpxclient)
91+
92+
with pytest.raises(ValueError, match="between 100 and 5000 characters long"):
93+
await service.get_resume_score(url="https://example.com/resume.pdf", job_description=job_description)
94+
95+
96+
@pytest.mark.asyncio
97+
@pytest.mark.parametrize("job_description", ["a" * 100, "a" * 5000])
98+
async def test_get_resume_score_accepts_boundary_lengths(
99+
httpxclient: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, job_description: str
100+
):
101+
service = ResumeScoreService(httpxclient)
102+
captured_request: dict[str, str] = {}
103+
104+
async def fake_send_post_request(path: str, data: dict[str, str], headers: dict[str, str] = {}):
105+
captured_request["path"] = path
106+
captured_request.update(data)
107+
return _make_resume_score_response()
108+
109+
monkeypatch.setattr(service, "_send_post_request", fake_send_post_request)
110+
111+
response = await service.get_resume_score(
112+
url="https://example.com/resume.pdf",
113+
job_description=job_description,
114+
)
115+
116+
assert isinstance(response, ResumeScoreResponse)
117+
assert response.data.score == 87
118+
assert response.usage.credits == 10
119+
assert captured_request["path"] == "resume-score"
120+
assert captured_request["url"] == "https://example.com/resume.pdf"
121+
assert captured_request["job_description"] == job_description

tests/services/test_webhook_url.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,25 @@
1414
from magicalapi.types.schemas import HttpResponse, WebhookCreatedResponse
1515

1616

17+
def _make_mock_transport() -> httpx.MockTransport:
18+
def handler(request: httpx.Request) -> httpx.Response:
19+
if request.method == "POST":
20+
body = json.loads(request.content.decode()) if request.content else {}
21+
return httpx.Response(200, json={"json": body}, request=request)
22+
23+
return httpx.Response(
24+
405, json={"message": "method not allowed"}, request=request
25+
)
26+
27+
return httpx.MockTransport(handler)
28+
29+
1730
@pytest_asyncio.fixture(scope="function")
1831
async def httpxclient() -> AsyncGenerator[httpx.AsyncClient]:
1932
"""Fixture to create an httpx client for testing."""
2033
client = httpx.AsyncClient(
2134
headers={"content-type": "application/json"},
35+
transport=_make_mock_transport(),
2236
)
2337

2438
yield client
@@ -30,15 +44,12 @@ async def httpxclient() -> AsyncGenerator[httpx.AsyncClient]:
3044
@pytest.mark.asyncio
3145
async def test_base_service_with_webhook_url(httpxclient: httpx.AsyncClient):
3246
"""Test that webhook_url is properly added to request body."""
33-
# Set a reasonable timeout for the client
34-
httpxclient._timeout = httpx.Timeout(30.0)
35-
3647
webhook_url = "https://example.com/webhook"
3748
base_service = BaseService(httpxclient, webhook_url=webhook_url)
3849
test_data = {"foo": "bar"}
3950

4051
response = await base_service._send_post_request(
41-
path="https://httpbin.org/post", data=test_data
52+
path="https://example.com/post", data=test_data
4253
)
4354

4455
# Verify response
@@ -58,7 +69,7 @@ async def test_base_service_without_webhook_url(httpxclient: httpx.AsyncClient):
5869
test_data = {"foo": "bar"}
5970

6071
response = await base_service._send_post_request(
61-
path="https://httpbin.org/post", data=test_data
72+
path="https://example.com/post", data=test_data
6273
)
6374

6475
# Verify response

0 commit comments

Comments
 (0)