Skip to content

Commit 6dd111b

Browse files
committed
feat: functional tests for headers and ok codes
1 parent 74d3875 commit 6dd111b

6 files changed

Lines changed: 331 additions & 7 deletions

File tree

docs/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
# -- Project information -----------------------------------------------------
1010

1111
project = "SDK"
12-
copyright = "2026, T Cloud"
13-
author = "T Cloud"
12+
copyright = "2026, T Cloud Public"
13+
author = "T Cloud Public"
1414
release = "0.1.0"
1515

1616
# -- General configuration ---------------------------------------------------

tests/functional/__init__.py

Whitespace-only changes.

tests/functional/conftest.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
5+
import httpx
6+
7+
from sdk.core.auth import AuthConfig
8+
from sdk.core.endpoint import CatalogEntry, build_endpoint_locator
9+
from sdk.core.provider import ProviderClient
10+
11+
12+
FAKE_CATALOG = [
13+
CatalogEntry(
14+
type="vpc",
15+
endpoints=[
16+
{
17+
"interface": "public",
18+
"region_id": "eu-de",
19+
"url": "https://vpc.eu-de.otc.t-systems.com/v1/test-project-id",
20+
},
21+
],
22+
),
23+
]
24+
25+
26+
def make_provider(
27+
handler: Callable[[httpx.Request], httpx.Response],
28+
) -> ProviderClient:
29+
cfg = AuthConfig(
30+
identity_endpoint="https://iam.eu-de.otc.t-systems.com/v3",
31+
access_key="AK_TEST",
32+
secret_key="SK_TEST",
33+
region="eu-de",
34+
)
35+
transport = httpx.MockTransport(handler)
36+
http_client = httpx.Client(transport=transport)
37+
provider = ProviderClient(cfg, http_client=http_client)
38+
39+
provider.project_id = "test-project-id"
40+
provider.token_id = "test-token"
41+
provider.endpoint_locator = build_endpoint_locator(
42+
FAKE_CATALOG, "eu-de",
43+
)
44+
45+
return provider

tests/functional/test_headers.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Functional tests for HTTP headers merging.
2+
3+
Verifies that the full pipeline (ProviderClient → ServiceClient →
4+
HTTP request) correctly merges headers from all layers:
5+
default, service-level, per-request, and auth.
6+
7+
Uses httpx.MockTransport to capture actual outgoing requests.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import httpx
13+
14+
from sdk.core.service_client import ServiceClient
15+
16+
from .conftest import make_provider
17+
18+
19+
class TestDefaultHeaders:
20+
"""Default headers (Accept, User-Agent) are always present."""
21+
22+
def test_accept_header(self):
23+
captured: list[httpx.Request] = []
24+
25+
def handler(req: httpx.Request) -> httpx.Response:
26+
captured.append(req)
27+
return httpx.Response(200, json={})
28+
29+
provider = make_provider(handler)
30+
client = ServiceClient(provider, "vpc")
31+
client.get("test-path")
32+
33+
assert "application/json" in captured[0].headers["accept"]
34+
35+
def test_user_agent_header(self):
36+
captured: list[httpx.Request] = []
37+
38+
def handler(req: httpx.Request) -> httpx.Response:
39+
captured.append(req)
40+
return httpx.Response(200, json={})
41+
42+
provider = make_provider(handler)
43+
client = ServiceClient(provider, "vpc")
44+
client.get("test-path")
45+
46+
assert "user-agent" in captured[0].headers
47+
48+
def test_content_type_on_json_body(self):
49+
"""POST with json body sets Content-Type: application/json."""
50+
captured: list[httpx.Request] = []
51+
52+
def handler(req: httpx.Request) -> httpx.Response:
53+
captured.append(req)
54+
return httpx.Response(200, json={})
55+
56+
provider = make_provider(handler)
57+
client = ServiceClient(provider, "vpc")
58+
59+
client.post("vpcs", json={"vpc": {"name": "test"}})
60+
61+
assert "application/json" in captured[0].headers["content-type"]
62+
63+
64+
class TestServiceHeaders:
65+
"""ServiceClient.extra_headers arrive in the final request."""
66+
67+
def test_extra_headers_sent(self):
68+
captured: list[httpx.Request] = []
69+
70+
def handler(req: httpx.Request) -> httpx.Response:
71+
captured.append(req)
72+
return httpx.Response(200, json={})
73+
74+
provider = make_provider(handler)
75+
client = ServiceClient(provider, "vpc")
76+
77+
client.extra_headers["X-Language"] = "en-us"
78+
client.extra_headers["X-Custom-Service"] = "my-value"
79+
80+
client.get("test-path")
81+
82+
assert captured[0].headers["x-language"] == "en-us"
83+
assert captured[0].headers["x-custom-service"] == "my-value"
84+
85+
86+
class TestPerRequestHeaders:
87+
"""Per-request headers override service-level headers."""
88+
89+
def test_override_service_header(self):
90+
captured: list[httpx.Request] = []
91+
92+
def handler(req: httpx.Request) -> httpx.Response:
93+
captured.append(req)
94+
return httpx.Response(200, json={})
95+
96+
provider = make_provider(handler)
97+
client = ServiceClient(provider, "vpc")
98+
client.extra_headers["X-Language"] = "en-us"
99+
100+
client.get("test-path", headers={"X-Language": "de-de"})
101+
102+
assert captured[0].headers["x-language"] == "de-de"
103+
104+
105+
class TestAuthHeaders:
106+
"""Auth headers are injected into the request."""
107+
108+
def test_aksk_authorization_present(self):
109+
captured: list[httpx.Request] = []
110+
111+
def handler(req: httpx.Request) -> httpx.Response:
112+
captured.append(req)
113+
return httpx.Response(200, json={})
114+
115+
provider = make_provider(handler)
116+
client = ServiceClient(provider, "vpc")
117+
client.get("test-path")
118+
119+
assert "authorization" in captured[0].headers
120+
121+
122+
class TestAllHeadersCombined:
123+
"""All header layers work together in a single request."""
124+
125+
def test_default_service_request_and_auth(self):
126+
captured: list[httpx.Request] = []
127+
128+
def handler(req: httpx.Request) -> httpx.Response:
129+
captured.append(req)
130+
return httpx.Response(200, json={})
131+
132+
provider = make_provider(handler)
133+
client = ServiceClient(provider, "vpc")
134+
client.extra_headers["X-Custom"] = "service-level"
135+
136+
client.get("test-path", headers={"X-Request-Id": "req-123"})
137+
138+
req = captured[0]
139+
assert "application/json" in captured[0].headers["accept"]
140+
assert "user-agent" in req.headers
141+
assert req.headers["x-custom"] == "service-level"
142+
assert req.headers["x-request-id"] == "req-123"
143+
assert "authorization" in req.headers

tests/functional/test_ok_codes.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Functional tests for ok_codes error handling.
2+
3+
Verifies that the full pipeline (ProviderClient → ServiceClient →
4+
HTTP request) correctly raises HttpError when the response status
5+
code is not in the expected ok_codes list.
6+
7+
Uses httpx.MockTransport to simulate server responses.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import httpx
13+
import pytest
14+
15+
from sdk.core.exceptions import HttpError
16+
from sdk.core.service_client import ServiceClient
17+
18+
from .conftest import make_provider
19+
20+
21+
class TestUnexpectedStatusRaises:
22+
"""Non-ok status codes must raise HttpError."""
23+
24+
def test_500_raises(self):
25+
def handler(req: httpx.Request) -> httpx.Response:
26+
return httpx.Response(500, json={"error": "internal"})
27+
28+
provider = make_provider(handler)
29+
client = ServiceClient(provider, "vpc")
30+
31+
with pytest.raises(HttpError) as exc_info:
32+
client.get("test-path")
33+
34+
assert exc_info.value.status_code == 500
35+
36+
def test_404_raises(self):
37+
def handler(req: httpx.Request) -> httpx.Response:
38+
return httpx.Response(404, json={"error": {"message": "not found"}})
39+
40+
provider = make_provider(handler)
41+
client = ServiceClient(provider, "vpc")
42+
43+
with pytest.raises(HttpError) as exc_info:
44+
client.get("vpcs/nonexistent-id")
45+
46+
assert exc_info.value.status_code == 404
47+
48+
def test_403_raises(self):
49+
def handler(req: httpx.Request) -> httpx.Response:
50+
return httpx.Response(403, json={"error": "forbidden"})
51+
52+
provider = make_provider(handler)
53+
client = ServiceClient(provider, "vpc")
54+
55+
with pytest.raises(HttpError) as exc_info:
56+
client.get("test-path")
57+
58+
assert exc_info.value.status_code == 403
59+
60+
61+
class TestCustomOkCodes:
62+
"""Custom ok_codes override default behavior."""
63+
64+
def test_custom_ok_codes_accepted(self):
65+
"""ok_codes=[204] allows 204 on GET (normally only 200)."""
66+
def handler(req: httpx.Request) -> httpx.Response:
67+
return httpx.Response(204)
68+
69+
provider = make_provider(handler)
70+
client = ServiceClient(provider, "vpc")
71+
72+
resp = client.get("test-path", ok_codes=[204])
73+
assert resp.status_code == 204
74+
75+
def test_custom_ok_codes_rejects_unexpected(self):
76+
"""ok_codes=[200] rejects 201."""
77+
def handler(req: httpx.Request) -> httpx.Response:
78+
return httpx.Response(201, json={})
79+
80+
provider = make_provider(handler)
81+
client = ServiceClient(provider, "vpc")
82+
83+
with pytest.raises(HttpError) as exc_info:
84+
client.get("test-path", ok_codes=[200])
85+
86+
assert exc_info.value.status_code == 201
87+
88+
89+
class TestDefaultOkCodesPerMethod:
90+
"""Each HTTP method has its own default ok_codes."""
91+
92+
def test_post_accepts_201(self):
93+
def handler(req: httpx.Request) -> httpx.Response:
94+
return httpx.Response(201, json={"vpc": {}})
95+
96+
provider = make_provider(handler)
97+
client = ServiceClient(provider, "vpc")
98+
99+
resp = client.post("vpcs", json={"vpc": {}})
100+
assert resp.status_code == 201
101+
102+
def test_delete_accepts_204(self):
103+
def handler(req: httpx.Request) -> httpx.Response:
104+
return httpx.Response(204)
105+
106+
provider = make_provider(handler)
107+
client = ServiceClient(provider, "vpc")
108+
109+
resp = client.delete("vpcs/some-id")
110+
assert resp.status_code == 204
111+
112+
def test_put_accepts_200(self):
113+
def handler(req: httpx.Request) -> httpx.Response:
114+
return httpx.Response(200, json={"vpc": {}})
115+
116+
provider = make_provider(handler)
117+
client = ServiceClient(provider, "vpc")
118+
119+
resp = client.put("vpcs/some-id", json={"vpc": {}})
120+
assert resp.status_code == 200
121+
122+
123+
class TestErrorContainsDebugInfo:
124+
"""HttpError must contain enough info for debugging."""
125+
126+
def test_status_code_in_error(self):
127+
def handler(req: httpx.Request) -> httpx.Response:
128+
return httpx.Response(409, json={"error": "conflict"})
129+
130+
provider = make_provider(handler)
131+
client = ServiceClient(provider, "vpc")
132+
133+
with pytest.raises(HttpError) as exc_info:
134+
client.post("vpcs", json={})
135+
136+
assert exc_info.value.status_code == 409

tests/unit/services/vpc/v1/test_requests.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def test_create_sends_post(self):
6767
result = vpc.create(client, opts)
6868

6969
client.post.assert_called_once_with(
70-
f"v1/{PROJECT_ID}/vpcs",
70+
f"vpcs",
7171
json={"vpc": {"name": "vpc", "cidr": "192.168.0.0/16"}},
7272
)
7373
assert result.id == "99d9d709-8478-4b46-9f3f-2206b1023fd3"
@@ -102,7 +102,7 @@ def test_get_sends_get(self):
102102
result = vpc.get(client, vpc_id)
103103

104104
client.get.assert_called_once_with(
105-
f"v1/{PROJECT_ID}/vpcs/{vpc_id}",
105+
f"vpcs/{vpc_id}",
106106
)
107107
assert result.id == vpc_id
108108
assert result.cidr == "192.168.0.0/16"
@@ -141,7 +141,7 @@ def test_list_with_opts(self):
141141
list(vpc.list(client, opts))
142142

143143
call_url = client.get.call_args[0][0]
144-
assert f"v1/{PROJECT_ID}/vpcs" in call_url
144+
assert f"vpcs" in call_url
145145

146146
def test_list_pagination(self):
147147
"""List follows marker pagination across two pages."""
@@ -195,7 +195,7 @@ def test_update_sends_put(self):
195195
result = vpc.update(client, vpc_id, opts)
196196

197197
client.put.assert_called_once_with(
198-
f"v1/{PROJECT_ID}/vpcs/{vpc_id}",
198+
f"vpcs/{vpc_id}",
199199
json={"vpc": {"name": "vpc1", "description": "test1"}},
200200
)
201201
assert result.name == "vpc1"
@@ -233,7 +233,7 @@ def test_delete_sends_delete(self):
233233
vpc.delete(client, vpc_id)
234234

235235
client.delete.assert_called_once_with(
236-
f"v1/{PROJECT_ID}/vpcs/{vpc_id}",
236+
f"vpcs/{vpc_id}",
237237
)
238238

239239
def test_delete_returns_none(self):

0 commit comments

Comments
 (0)