-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_serialization.py
More file actions
329 lines (231 loc) · 12.1 KB
/
Copy pathtest_serialization.py
File metadata and controls
329 lines (231 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""Query serialisation, date formatters and response processing."""
from __future__ import annotations
from datetime import date, datetime, timezone
import httpx
import pytest
import respx
from pydantic import BaseModel
from conftest import SleepRecorder
from skylink_api._client import SkyLink
from skylink_api._exceptions import APIResponseValidationError
from skylink_api._qs import (
build_query,
format_bbox,
format_history_datetime,
format_query_value,
format_schedule_date,
format_ticket_date,
)
from skylink_api._response import (
RateLimitInfo,
parse_rate_limit,
parse_retry_after,
process_response,
)
from skylink_api._types import NOT_GIVEN, RequestSpec
class Airline(BaseModel):
id: int
name: str
iata: str | None = None
# ── query values ─────────────────────────────────────────────────────────────
def test_none_and_not_given_are_dropped() -> None:
assert build_query({"a": 1, "b": None, "c": NOT_GIVEN}) == {"a": "1"}
def test_booleans_are_lowercase_strings() -> None:
# FastAPI rejects Python's "True"/"False".
assert build_query({"parsed": True, "photos": False}) == {"parsed": "true", "photos": "false"}
def test_numbers_and_strings() -> None:
assert build_query({"limit": 100, "radius": 12.5, "icao": "KJFK"}) == {
"limit": "100",
"radius": "12.5",
"icao": "KJFK",
}
def test_zero_and_empty_string_are_kept() -> None:
assert build_query({"offset": 0, "q": ""}) == {"offset": "0", "q": ""}
def test_bbox_tuple_becomes_a_single_string() -> None:
assert build_query({"bbox": (40.0, -74.5, 41.0, -73.5)}) == {"bbox": "40.0,-74.5,41.0,-73.5"}
def test_format_bbox_helper() -> None:
assert format_bbox((40.0, -74.5, 41.0, -73.5)) == "40.0,-74.5,41.0,-73.5"
assert format_bbox([40, -74, 41, -73]) == "40,-74,41,-73"
assert format_bbox("40,-74,41,-73") == "40,-74,41,-73"
with pytest.raises(ValueError, match="bbox must have 4 values"):
format_bbox((1.0, 2.0))
def test_lists_become_csv() -> None:
# exclude_qcode / exclude_scope are comma separated on the wire.
assert build_query({"exclude_scope": ["AERODROME", "FIR"]}) == {
"exclude_scope": "AERODROME,FIR"
}
assert build_query({"exclude_qcode": []}) == {}
def test_datetimes_default_to_iso() -> None:
moment = datetime(2026, 8, 12, 6, 30, tzinfo=timezone.utc)
assert build_query({"start": moment})["start"] == "2026-08-12T06:30:00+00:00"
assert build_query({"day": date(2026, 8, 12)})["day"] == "2026-08-12"
def test_later_sources_win_and_none_removes() -> None:
assert build_query({"limit": 10, "photos": True}, {"limit": 50}) == {
"limit": "50",
"photos": "true",
}
assert build_query({"limit": 10}, {"limit": None}) == {}
def test_mapping_values_are_rejected() -> None:
with pytest.raises(TypeError):
format_query_value({"nested": 1})
# ── per-endpoint date formats ────────────────────────────────────────────────
def test_schedules_want_dd_mm_yyyy() -> None:
assert format_schedule_date(date(2026, 2, 5)) == "05-02-2026"
assert format_schedule_date(datetime(2026, 2, 5, 10, 30)) == "05-02-2026"
assert format_schedule_date("2026-02-05") == "05-02-2026"
# Already in the endpoint's format — passed through untouched.
assert format_schedule_date("05-02-2026") == "05-02-2026"
def test_tickets_want_yyyy_mm_dd() -> None:
assert format_ticket_date(date(2026, 2, 5)) == "2026-02-05"
assert format_ticket_date(datetime(2026, 2, 5, 10, 30)) == "2026-02-05"
assert format_ticket_date("2026-02-05") == "2026-02-05"
def test_history_wants_iso_8601() -> None:
moment = datetime(2026, 8, 12, 6, 30, 15, tzinfo=timezone.utc)
assert format_history_datetime(moment) == "2026-08-12T06:30:15+00:00"
assert format_history_datetime(date(2026, 8, 12)) == "2026-08-12"
assert format_history_datetime("2026-08-12T06:30:15Z") == "2026-08-12T06:30:15Z"
# ── response processing ──────────────────────────────────────────────────────
def _spec(**kwargs: object) -> RequestSpec:
base: dict[str, object] = {"method": "GET", "path": "/x"}
base.update(kwargs)
return RequestSpec(**base) # type: ignore[arg-type]
def test_json_without_cast_returns_plain_data() -> None:
response = httpx.Response(200, json={"a": 1})
assert process_response(_spec(), response) == {"a": 1}
def test_json_cast_to_model() -> None:
response = httpx.Response(200, json={"id": 1, "name": "British Airways", "iata": "BA"})
airline = process_response(_spec(cast_to=Airline), response)
assert isinstance(airline, Airline)
assert airline.name == "British Airways"
def test_json_cast_to_list_of_models() -> None:
response = httpx.Response(200, json=[{"id": 1, "name": "BA"}, {"id": 2, "name": "AA"}])
airlines = process_response(_spec(cast_to=list[Airline]), response)
assert [airline.id for airline in airlines] == [1, 2]
def test_cast_failure_raises_response_validation_error() -> None:
response = httpx.Response(200, json={"id": "not-an-int", "name": "BA"})
with pytest.raises(APIResponseValidationError) as excinfo:
process_response(_spec(cast_to=Airline), response)
assert excinfo.value.status_code == 200
def test_invalid_json_raises_response_validation_error() -> None:
response = httpx.Response(200, text="<html>oops</html>")
with pytest.raises(APIResponseValidationError):
process_response(_spec(), response)
def test_bytes_kind_returns_raw_content() -> None:
response = httpx.Response(200, content=b"%PDF-1.4 ...")
assert process_response(_spec(response_kind="bytes"), response) == b"%PDF-1.4 ..."
def test_text_kind_returns_str() -> None:
response = httpx.Response(200, text="ORIGIN KJFK ...")
assert process_response(_spec(response_kind="text"), response) == "ORIGIN KJFK ..."
def test_none_kind_returns_none() -> None:
"""DELETE /webhooks/{id} answers 204 with no body."""
response = httpx.Response(204)
assert process_response(_spec(response_kind="none"), response) is None
def test_empty_json_body_returns_none() -> None:
assert process_response(_spec(cast_to=Airline), httpx.Response(200)) is None
# ── rate limit headers ───────────────────────────────────────────────────────
def test_rate_limit_headers_are_parsed() -> None:
headers = {
"X-RateLimit-Requests-Limit": "1000",
"X-RateLimit-Requests-Remaining": "997",
"X-RateLimit-Requests-Reset": "86400",
}
assert parse_rate_limit(headers) == RateLimitInfo(limit=1000, remaining=997, reset=86400)
def test_rate_limit_header_lookup_is_case_insensitive() -> None:
assert parse_rate_limit({"x-ratelimit-requests-limit": "5"}) == RateLimitInfo(limit=5)
def test_rate_limit_missing_headers() -> None:
assert parse_rate_limit({}) is None
assert parse_rate_limit({"Content-Type": "application/json"}) is None
def test_rate_limit_garbage_values_are_ignored() -> None:
assert parse_rate_limit({"X-RateLimit-Requests-Limit": "n/a"}) is None
def test_direct_channel_rate_limit_headers_are_parsed() -> None:
"""The direct gateway (APISIX) omits the ``Requests`` infix — verbatim capture."""
headers = {
"X-RateLimit-Limit": "750000",
"X-RateLimit-Remaining": "749996",
"X-RateLimit-Reset": "86385",
}
assert parse_rate_limit(headers) == RateLimitInfo(limit=750000, remaining=749996, reset=86385)
def test_direct_channel_rate_limit_headers_are_case_insensitive() -> None:
"""Live direct responses spell them ``X-Ratelimit-Limit``, lowercase ``l``."""
headers = {
"X-Ratelimit-Limit": "750000",
"X-Ratelimit-Remaining": "749996",
"X-Ratelimit-Reset": "86385",
}
assert parse_rate_limit(headers) == RateLimitInfo(limit=750000, remaining=749996, reset=86385)
def test_rapidapi_plan_quota_wins_over_the_other_header_families() -> None:
"""All three families at once: the plan's ``Requests`` quota is the answer.
A live RapidAPI response carries the plan quota, the marketplace's global
free-tier ceiling (``rapid-free-plans-hard-limit``) and — depending on the
edge — the generic names too. Reporting either of the other two would show
the caller numbers that have nothing to do with their subscription.
"""
headers = {
"X-RateLimit-Requests-Limit": "10000",
"X-RateLimit-Requests-Remaining": "8938",
"X-RateLimit-Requests-Reset": "319216",
"X-RateLimit-rapid-free-plans-hard-limit-limit": "500",
"X-RateLimit-rapid-free-plans-hard-limit-remaining": "499",
"X-RateLimit-rapid-free-plans-hard-limit-reset": "60",
"X-RateLimit-Limit": "750000",
"X-RateLimit-Remaining": "749996",
"X-RateLimit-Reset": "86385",
}
assert parse_rate_limit(headers) == RateLimitInfo(limit=10000, remaining=8938, reset=319216)
def test_rapid_free_plans_noise_alone_is_not_a_quota() -> None:
"""The noisy triplet matches neither family, so it is ignored entirely."""
headers = {
"X-RateLimit-rapid-free-plans-hard-limit-limit": "500",
"X-RateLimit-rapid-free-plans-hard-limit-remaining": "499",
"X-RateLimit-rapid-free-plans-hard-limit-reset": "60",
}
assert parse_rate_limit(headers) is None
def test_unparseable_plan_quota_falls_back_to_the_generic_family() -> None:
"""A family that yields no number at all is skipped, not treated as the answer."""
headers = {
"X-RateLimit-Requests-Limit": "n/a",
"X-RateLimit-Limit": "750000",
"X-RateLimit-Remaining": "749996",
}
assert parse_rate_limit(headers) == RateLimitInfo(limit=750000, remaining=749996)
def test_retry_after_parsing() -> None:
assert parse_retry_after({"Retry-After": "5"}) == 5.0
assert parse_retry_after({"retry-after": "0"}) == 0.0
assert parse_retry_after({"Retry-After": "9999"}) == 60.0
assert parse_retry_after({"Retry-After": "not-a-date"}) is None
assert parse_retry_after({}) is None
# ── end to end through the client ────────────────────────────────────────────
def test_query_is_serialised_on_the_wire(
respx_mock: respx.MockRouter, sleeper: SleepRecorder
) -> None:
route = respx_mock.route().mock(return_value=httpx.Response(200, json={"ok": True}))
with SkyLink(api_key="k", sleep=sleeper, environ={}) as sky:
sky.request(
"GET",
"/adsb/aircraft",
query={
"bbox": (40.0, -74.5, 41.0, -73.5),
"photos": False,
"limit": 25,
"callsign": None,
},
)
params = route.calls.last.request.url.params
assert params["bbox"] == "40.0,-74.5,41.0,-73.5"
assert params["photos"] == "false"
assert params["limit"] == "25"
assert "callsign" not in params
def test_per_request_query_is_merged(respx_mock: respx.MockRouter, sleeper: SleepRecorder) -> None:
route = respx_mock.route().mock(return_value=httpx.Response(200, json={"ok": True}))
with SkyLink(api_key="k", sleep=sleeper, environ={}) as sky:
sky.request("GET", "/navaids", query={"limit": 10}, options={"query": {"country": "US"}})
params = route.calls.last.request.url.params
assert params["limit"] == "10"
assert params["country"] == "US"
def test_client_casts_into_models(respx_mock: respx.MockRouter, sleeper: SleepRecorder) -> None:
respx_mock.route().mock(
return_value=httpx.Response(200, json=[{"id": 1, "name": "British Airways", "iata": "BA"}])
)
with SkyLink(api_key="k", sleep=sleeper, environ={}) as sky:
airlines = sky.request("GET", "/airlines/search", cast_to=list[Airline])
assert airlines[0].iata == "BA"