|
3 | 3 | import pytest |
4 | 4 |
|
5 | 5 | from blockscout_mcp_server.config import config |
6 | | -from blockscout_mcp_server.pro_api_key_context import _client_key_state, _Valid |
| 6 | +from blockscout_mcp_server.pro_api_key_context import CreditSink, _client_key_state, _credit_sink, _Valid |
7 | 7 | from blockscout_mcp_server.tools.common import ( |
8 | 8 | ChainNotFoundError, |
9 | 9 | ensure_chain_supported, |
@@ -206,3 +206,50 @@ async def test_make_blockscout_request_client_key_via_context_var(monkeypatch): |
206 | 206 | assert "timestamp" in response_data |
207 | 207 | assert isinstance(response_data["gas_used"], str) # Blockscout API returns this as a string |
208 | 208 | assert "parent_hash" in response_data |
| 209 | + |
| 210 | + |
| 211 | +@pytest.mark.integration |
| 212 | +@pytest.mark.asyncio |
| 213 | +async def test_credit_capture_on_real_pro_api_response(): |
| 214 | + """ |
| 215 | + Confirms that a real metered PRO API GET response carries ``x-credits-remaining`` |
| 216 | + and that our capture code records a numeric value into the CreditSink. |
| 217 | +
|
| 218 | + This validates the request/response contract the whole feature rests on: the |
| 219 | + header actually arrives on a live response and the sink records it. POST and |
| 220 | + metadata surface coverage is already proven at unit level in Phase 2's |
| 221 | + parameterized test; live coverage of a single GET here avoids network |
| 222 | + flakiness without omitting anything meaningful. |
| 223 | + """ |
| 224 | + # ARRANGE — skip immediately when no key is configured; without an authenticated |
| 225 | + # metered request the header would never be returned and the test would be |
| 226 | + # measuring the wrong thing. |
| 227 | + if not config.pro_api_key: |
| 228 | + pytest.skip("BLOCKSCOUT_PRO_API_KEY not configured; credit capture requires an authenticated metered request") |
| 229 | + |
| 230 | + sink = CreditSink() |
| 231 | + token = _credit_sink.set(sink) |
| 232 | + try: |
| 233 | + chain_id = "100" # Gnosis Chain — same stable target as the existing block test |
| 234 | + block_number = "46282564" |
| 235 | + api_path = f"/api/v2/blocks/{block_number}" |
| 236 | + |
| 237 | + # ACT — real network call; retry_on_network_error skips on transient failures |
| 238 | + # rather than letting flaky infrastructure cause a false negative. |
| 239 | + await retry_on_network_error( |
| 240 | + lambda: make_blockscout_request(chain_id=chain_id, api_path=api_path), |
| 241 | + action_description="PRO API block request for credit-capture verification", |
| 242 | + ) |
| 243 | + finally: |
| 244 | + _credit_sink.reset(token) |
| 245 | + |
| 246 | + # ASSERT — the header was present and the capture recorded a numeric value. |
| 247 | + # We do NOT assert a specific magnitude: the balance may be any value |
| 248 | + # (including negative for overdraft accounts). |
| 249 | + assert sink.remaining is not None, ( |
| 250 | + "x-credits-remaining was not captured — the header may be absent on this endpoint " |
| 251 | + "or the capture logic in _make_blockscout_http_request is broken." |
| 252 | + ) |
| 253 | + assert isinstance(sink.remaining, (int, float)), ( |
| 254 | + f"Expected a numeric remaining-credits value, got {type(sink.remaining).__name__!r}: {sink.remaining!r}" |
| 255 | + ) |
0 commit comments