|
| 1 | +"""#145 -- a human sentence in ``data.error`` is a message, never ``error.code``. |
| 2 | +
|
| 3 | +The API answers many failures with the JSend fail envelope, |
| 4 | +``{"status": "fail", "data": {"error": ..., "message"?: ...}}`` (``render_fail`` |
| 5 | +in ``V1::BaseController``). ``data.error`` holds one of two things: |
| 6 | +
|
| 7 | +* a machine code, with the sentence in ``data.message``: ``VALIDATION_ERROR``, |
| 8 | + ``INTERVAL_FLOOR``, ``WATCH_LIMIT``, and the lower-snake ``invalid_code``, |
| 9 | + ``no_price_data`` and ``invalid_request`` that ``api_validations.rb`` and |
| 10 | + ``prices_controller.rb`` send (origin/main, 2026-09-13); |
| 11 | +* the sentence itself, as every ``V1::FuelSurchargeController`` 400/404 does. |
| 12 | +
|
| 13 | +``error_from_response`` copied either one into ``error.code``, so a caller |
| 14 | +branching on ``code`` saw a unique free-text string per request. |
| 15 | +
|
| 16 | +Bodies marked "live" are verbatim from ``api.oilpriceapi.com`` on 2026-09-13. |
| 17 | +Client tests drive the REAL sync and async clients over a mocked transport. |
| 18 | +""" |
| 19 | + |
| 20 | +import asyncio |
| 21 | +from unittest.mock import AsyncMock, patch |
| 22 | + |
| 23 | +import httpx |
| 24 | +import pytest |
| 25 | + |
| 26 | +from oilpriceapi import AsyncOilPriceAPI, OilPriceAPI |
| 27 | +from oilpriceapi.exceptions import ( |
| 28 | + AuthenticationError, |
| 29 | + BadRequestError, |
| 30 | + DataNotFoundError, |
| 31 | + OilPriceAPIError, |
| 32 | + PaymentRequiredError, |
| 33 | + PermissionDeniedError, |
| 34 | + RateLimitError, |
| 35 | + ServerError, |
| 36 | + ValidationError, |
| 37 | + error_from_response, |
| 38 | +) |
| 39 | + |
| 40 | +# Not a credential: a fixture string. Every request here is mocked. |
| 41 | +FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"]) |
| 42 | + |
| 43 | +MODES = ["sync", "async"] |
| 44 | + |
| 45 | +# live: GET /v1/fuel-surcharge/nope/latest |
| 46 | +UNKNOWN_CARRIER_404 = { |
| 47 | + "status": "fail", |
| 48 | + "data": { |
| 49 | + "error": "Unknown carrier 'nope'. Covered carriers: odfl, saia, estes, xpo, abf, tforce, averitt, southeastern-freight.", |
| 50 | + "covered_carriers": ["odfl", "saia", "estes", "xpo", "abf", "tforce", "averitt", "southeastern-freight"], |
| 51 | + "hint": "Call GET /v1/fuel-surcharge to list every covered carrier with its latest surcharge.", |
| 52 | + }, |
| 53 | +} |
| 54 | + |
| 55 | +# live: POST /v1/subscriptions {"codes": [], "interval_seconds": 86400} |
| 56 | +CODES_BLANK_422 = { |
| 57 | + "status": "fail", |
| 58 | + "data": { |
| 59 | + "error": "VALIDATION_ERROR", |
| 60 | + "message": "Codes can't be blank", |
| 61 | + "details": {"codes": ["can't be blank"]}, |
| 62 | + }, |
| 63 | +} |
| 64 | + |
| 65 | +# live: POST /v1/subscriptions {"interval_seconds": -5}; `upgrade.plans` trimmed to one entry. |
| 66 | +INTERVAL_FLOOR_402 = { |
| 67 | + "status": "fail", |
| 68 | + "data": { |
| 69 | + "error": "INTERVAL_FLOOR", |
| 70 | + "message": "Your plan's minimum snapshot interval is 60s (requested -5s). Upgrade to poll faster.", |
| 71 | + "limit": 60, |
| 72 | + "current": -5, |
| 73 | + "upgrade_trigger": "interval_floor", |
| 74 | + "upgrade_url": "https://www.oilpriceapi.com/pricing?plan=enterprise_v2&utm_source=api&utm_medium=agent&utm_campaign=agent_min_interval", |
| 75 | + "upgrade": { |
| 76 | + "url": "https://www.oilpriceapi.com/pricing?plan=enterprise_v2&utm_source=api&utm_medium=agent&utm_campaign=agent_min_interval", |
| 77 | + "next_tier": "enterprise_v2", |
| 78 | + "plans": [{"plan": "developer", "requests": 10000, "watches": 3}], |
| 79 | + }, |
| 80 | + }, |
| 81 | +} |
| 82 | + |
| 83 | +# live: GET /v1/subscriptions/<unknown uuid> |
| 84 | +NOT_FOUND_404 = { |
| 85 | + "error": { |
| 86 | + "code": "NOT_FOUND", |
| 87 | + "message": "Subscription not found", |
| 88 | + "status": 404, |
| 89 | + "request_id": "6598da0c-4289-470f-84f3-b4c3d56198b3", |
| 90 | + "docs": "https://docs.oilpriceapi.com#NOT_FOUND", |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +# live: GET /v1/prices/latest?by_code=NOT_A_REAL_CODE_XYZ (lower-snake code + sentence, |
| 95 | +# app/controllers/concerns/api_validations.rb). |
| 96 | +INVALID_CODE_400 = { |
| 97 | + "status": "fail", |
| 98 | + "data": { |
| 99 | + "error": "invalid_code", |
| 100 | + "message": "Code 'NOT_A_REAL_CODE_XYZ' not found. No close match found. See /v1/commodities for all available codes.", |
| 101 | + "suggestions": [], |
| 102 | + "did_you_mean": [], |
| 103 | + "invalid_codes": ["NOT_A_REAL_CODE_XYZ"], |
| 104 | + "all_codes_url": "https://api.oilpriceapi.com/v1/commodities", |
| 105 | + }, |
| 106 | +} |
| 107 | + |
| 108 | + |
| 109 | +def _response(status, body): |
| 110 | + request = httpx.Request( |
| 111 | + "GET", |
| 112 | + "https://api.oilpriceapi.com/v1/fuel-surcharge/nope/latest", |
| 113 | + headers={"Authorization": f"Token {FIXTURE_KEY}"}, |
| 114 | + ) |
| 115 | + return httpx.Response(status, json=body, request=request) |
| 116 | + |
| 117 | + |
| 118 | +def _raised(mode, status, body): |
| 119 | + """Return the error the real client raises for ``body``.""" |
| 120 | + response = _response(status, body) |
| 121 | + if mode == "sync": |
| 122 | + with OilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) as client: |
| 123 | + with patch.object(client._client, "request", return_value=response): |
| 124 | + with pytest.raises(OilPriceAPIError) as info: |
| 125 | + client.request("GET", "/v1/fuel-surcharge/nope/latest") |
| 126 | + return info.value |
| 127 | + |
| 128 | + async def go(): |
| 129 | + async with AsyncOilPriceAPI(api_key=FIXTURE_KEY, max_retries=1) as client: |
| 130 | + with patch.object(client._client, "request", new=AsyncMock(return_value=response)): |
| 131 | + with pytest.raises(OilPriceAPIError) as info: |
| 132 | + await client.request("GET", "/v1/fuel-surcharge/nope/latest") |
| 133 | + return info.value |
| 134 | + |
| 135 | + return asyncio.run(go()) |
| 136 | + |
| 137 | + |
| 138 | +# --- the defect ---------------------------------------------------------------- |
| 139 | + |
| 140 | + |
| 141 | +@pytest.mark.parametrize("mode", MODES) |
| 142 | +def test_sentence_in_fail_envelope_is_the_message_not_the_code(mode): |
| 143 | + error = _raised(mode, 404, UNKNOWN_CARRIER_404) |
| 144 | + |
| 145 | + assert isinstance(error, DataNotFoundError) |
| 146 | + assert error.code is None |
| 147 | + assert error.machine_code is None |
| 148 | + assert error.message == UNKNOWN_CARRIER_404["data"]["error"] |
| 149 | + assert error.suggestions == UNKNOWN_CARRIER_404["data"]["covered_carriers"] |
| 150 | + assert error.raw_body == UNKNOWN_CARRIER_404 |
| 151 | + |
| 152 | + |
| 153 | +@pytest.mark.parametrize( |
| 154 | + ("status", "expected_type"), |
| 155 | + [ |
| 156 | + (400, BadRequestError), |
| 157 | + (401, AuthenticationError), |
| 158 | + (402, PaymentRequiredError), |
| 159 | + (403, PermissionDeniedError), |
| 160 | + (404, DataNotFoundError), |
| 161 | + (422, ValidationError), |
| 162 | + (429, RateLimitError), |
| 163 | + (503, ServerError), |
| 164 | + ], |
| 165 | +) |
| 166 | +def test_no_status_class_turns_a_sentence_into_a_code(status, expected_type): |
| 167 | + body = {"status": "fail", "data": {"error": "period is not supported by the latest endpoint; use /v1/rig-counts/historical"}} |
| 168 | + |
| 169 | + error = error_from_response(_response(status, body)) |
| 170 | + |
| 171 | + assert isinstance(error, expected_type) |
| 172 | + assert error.status_code == status |
| 173 | + assert error.code is None |
| 174 | + assert error.message == body["data"]["error"] |
| 175 | + |
| 176 | + |
| 177 | +def test_sentence_without_the_status_key_is_not_a_code_either(): |
| 178 | + body = {"data": {"error": "Parcel fuel-surcharge history requires a service_level parameter."}} |
| 179 | + |
| 180 | + error = error_from_response(_response(400, body)) |
| 181 | + |
| 182 | + assert error.code is None |
| 183 | + assert error.message == body["data"]["error"] |
| 184 | + |
| 185 | + |
| 186 | +# --- what must not change ------------------------------------------------------ |
| 187 | + |
| 188 | + |
| 189 | +@pytest.mark.parametrize("mode", MODES) |
| 190 | +def test_upper_snake_code_is_the_code_and_message_is_the_sentence(mode): |
| 191 | + error = _raised(mode, 422, CODES_BLANK_422) |
| 192 | + |
| 193 | + assert isinstance(error, ValidationError) |
| 194 | + assert error.status_code == 422 |
| 195 | + assert error.code == "VALIDATION_ERROR" |
| 196 | + assert error.machine_code == "VALIDATION_ERROR" |
| 197 | + assert error.message == "Codes can't be blank" |
| 198 | + |
| 199 | + |
| 200 | +@pytest.mark.parametrize("mode", MODES) |
| 201 | +def test_upgrade_trigger_keeps_code_message_and_remediation(mode): |
| 202 | + error = _raised(mode, 402, INTERVAL_FLOOR_402) |
| 203 | + |
| 204 | + assert isinstance(error, PaymentRequiredError) |
| 205 | + assert error.code == "INTERVAL_FLOOR" |
| 206 | + assert error.message.startswith("Your plan's minimum snapshot interval is 60s") |
| 207 | + assert error.remediation_url == INTERVAL_FLOOR_402["data"]["upgrade_url"] |
| 208 | + |
| 209 | + |
| 210 | +@pytest.mark.parametrize("mode", MODES) |
| 211 | +def test_lower_snake_code_the_api_sends_stays_the_code(mode): |
| 212 | + error = _raised(mode, 400, INVALID_CODE_400) |
| 213 | + |
| 214 | + assert isinstance(error, BadRequestError) |
| 215 | + assert error.code == "invalid_code" |
| 216 | + assert error.message == INVALID_CODE_400["data"]["message"] |
| 217 | + assert error.invalid_codes == ["NOT_A_REAL_CODE_XYZ"] |
| 218 | + |
| 219 | + |
| 220 | +def test_code_with_no_sentence_is_both_code_and_message(): |
| 221 | + error = error_from_response(_response(402, {"status": "fail", "data": {"error": "WATCH_LIMIT"}})) |
| 222 | + |
| 223 | + assert error.code == "WATCH_LIMIT" |
| 224 | + assert error.message == "WATCH_LIMIT" |
| 225 | + |
| 226 | + |
| 227 | +@pytest.mark.parametrize("mode", MODES) |
| 228 | +def test_canonical_nested_error_object_keeps_precedence(mode): |
| 229 | + error = _raised(mode, 404, NOT_FOUND_404) |
| 230 | + |
| 231 | + assert isinstance(error, DataNotFoundError) |
| 232 | + assert error.code == "NOT_FOUND" |
| 233 | + assert error.message == "Subscription not found" |
| 234 | + assert error.request_id == "6598da0c-4289-470f-84f3-b4c3d56198b3" |
| 235 | + |
| 236 | + |
| 237 | +def test_error_object_nested_under_fail_data_keeps_precedence(): |
| 238 | + body = {"status": "fail", "data": {"error": {"code": "DATA_NOT_AVAILABLE", "message": "Not yet published"}}} |
| 239 | + |
| 240 | + error = error_from_response(_response(404, body)) |
| 241 | + |
| 242 | + assert error.code == "DATA_NOT_AVAILABLE" |
| 243 | + assert error.message == "Not yet published" |
| 244 | + |
| 245 | + |
| 246 | +# --- redaction is unchanged ---------------------------------------------------- |
| 247 | + |
| 248 | + |
| 249 | +@pytest.mark.parametrize("mode", MODES) |
| 250 | +def test_key_in_a_fail_sentence_is_redacted_and_not_used_as_code(mode): |
| 251 | + body = {"status": "fail", "data": {"error": f"API key {FIXTURE_KEY} cannot use this route"}} |
| 252 | + |
| 253 | + error = _raised(mode, 403, body) |
| 254 | + |
| 255 | + assert error.code is None |
| 256 | + assert error.message == "API key [REDACTED] cannot use this route" |
| 257 | + for rendered in (error.message, str(error), repr(error.raw_body), error.raw_text): |
| 258 | + assert FIXTURE_KEY not in rendered |
| 259 | + |
| 260 | + |
| 261 | +@pytest.mark.parametrize("mode", MODES) |
| 262 | +def test_key_in_a_fail_message_is_redacted_beside_a_machine_code(mode): |
| 263 | + body = {"status": "fail", "data": {"error": "VALIDATION_ERROR", "message": f"Token {FIXTURE_KEY} rejected"}} |
| 264 | + |
| 265 | + error = _raised(mode, 422, body) |
| 266 | + |
| 267 | + assert error.code == "VALIDATION_ERROR" |
| 268 | + # main redacts the whole "Token <key>" header value first; unchanged here. |
| 269 | + assert error.message == "[REDACTED] rejected" |
| 270 | + for rendered in (error.message, str(error), repr(error.raw_body), error.raw_text): |
| 271 | + assert FIXTURE_KEY not in rendered |
0 commit comments