Phase 1: public market + universe lookups via ESI
Add a thin async ESI client (httpx, descriptive User-Agent, uniform errors, mockable transport) plus name<->id resolution. New read-only tools: - universe: resolve_names, get_item, get_system, get_station, get_region, get_route - market: list_hubs, get_market_price, get_market_orders, compare_hubs Trade-hub IDs (Jita/Amarr/Dodixie/Rens/Hek) are hardcoded constants. Market aggregation (best buy/sell, spread, order book) lives in pure helpers. 30 unit tests with mocked ESI; coverage 99%. SDE bulk-load deferred to a later phase. Closes #2 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
11 files changed
+839
-1
No files matched your search
@@ -0,0 +1,49 @@
|
||||
"""Shared test helpers: a mock ESI client backed by httpx.MockTransport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from familiar import esi
|
||||
|
||||
Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_esi():
|
||||
"""Install a mock ESI client driven by a request handler.
|
||||
|
||||
Usage::
|
||||
|
||||
def test_x(mock_esi):
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={...})
|
||||
mock_esi(handler)
|
||||
...
|
||||
|
||||
The shared client is reset after the test.
|
||||
"""
|
||||
|
||||
def _make(handler: Handler) -> esi.EsiClient:
|
||||
client = esi.EsiClient(transport=httpx.MockTransport(handler))
|
||||
esi.set_client(client)
|
||||
return client
|
||||
|
||||
yield _make
|
||||
esi.set_client(None)
|
||||
|
||||
|
||||
def route(request: httpx.Request, fixtures: dict[str, object]) -> httpx.Response:
|
||||
"""Match a request against ``{substring_in_path: json_body}`` and respond.
|
||||
|
||||
The special key ``"/universe/ids/"`` is matched for the name-resolution POST.
|
||||
A missing match returns 404 so tests fail loudly.
|
||||
"""
|
||||
path = request.url.path
|
||||
for needle, body in fixtures.items():
|
||||
if needle in path:
|
||||
return httpx.Response(200, json=body)
|
||||
return httpx.Response(404, json={"error": f"no fixture for {path}"})
|
||||
@@ -0,0 +1,56 @@
|
||||
"""ESI client: error handling, empty bodies, datasource param."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from familiar import esi
|
||||
|
||||
|
||||
def test_get_raises_esi_error_on_http_error(mock_esi):
|
||||
mock_esi(lambda req: httpx.Response(404, json={"error": "not found"}))
|
||||
with pytest.raises(esi.EsiError) as exc:
|
||||
asyncio.run(esi.client().get("/universe/types/999999/"))
|
||||
assert exc.value.status == 404
|
||||
assert "404" in str(exc.value)
|
||||
|
||||
|
||||
def test_get_raises_on_network_error(mock_esi):
|
||||
def boom(req):
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
mock_esi(boom)
|
||||
with pytest.raises(esi.EsiError):
|
||||
asyncio.run(esi.client().get("/anything/"))
|
||||
|
||||
|
||||
def test_empty_body_returns_none(mock_esi):
|
||||
mock_esi(lambda req: httpx.Response(200, content=b""))
|
||||
assert asyncio.run(esi.client().get("/x/")) is None
|
||||
|
||||
|
||||
def test_datasource_param_is_attached(mock_esi):
|
||||
seen = {}
|
||||
|
||||
def handler(req):
|
||||
seen["datasource"] = req.url.params.get("datasource")
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
mock_esi(handler)
|
||||
asyncio.run(esi.client().get("/x/"))
|
||||
assert seen["datasource"] == "tranquility"
|
||||
|
||||
|
||||
def test_error_detail_falls_back_to_text(mock_esi):
|
||||
mock_esi(lambda req: httpx.Response(500, content=b"upstream exploded"))
|
||||
with pytest.raises(esi.EsiError) as exc:
|
||||
asyncio.run(esi.client().get("/x/"))
|
||||
assert "upstream exploded" in str(exc.value)
|
||||
|
||||
|
||||
def test_default_client_is_singleton():
|
||||
esi.set_client(None)
|
||||
first = esi.client()
|
||||
assert esi.client() is first
|
||||
esi.set_client(None)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Name <-> id resolution."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from familiar import lookup
|
||||
|
||||
|
||||
def test_resolve_id_passes_through_numeric():
|
||||
assert asyncio.run(lookup.resolve_id(34)) == 34
|
||||
assert asyncio.run(lookup.resolve_id("34")) == 34
|
||||
|
||||
|
||||
def test_resolve_name_picks_requested_category(mock_esi):
|
||||
body = {
|
||||
"inventory_types": [{"id": 34, "name": "Tritanium"}],
|
||||
"systems": [{"id": 30000142, "name": "Jita"}],
|
||||
}
|
||||
mock_esi(lambda req: httpx.Response(200, json=body))
|
||||
assert asyncio.run(lookup.resolve_name("Tritanium", "inventory_types")) == (
|
||||
34,
|
||||
"inventory_types",
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_id_resolves_name(mock_esi):
|
||||
body = {"inventory_types": [{"id": 34, "name": "Tritanium"}]}
|
||||
mock_esi(lambda req: httpx.Response(200, json=body))
|
||||
assert asyncio.run(lookup.resolve_id("Tritanium", "inventory_types")) == 34
|
||||
|
||||
|
||||
def test_resolve_name_raises_when_absent(mock_esi):
|
||||
mock_esi(lambda req: httpx.Response(200, json={}))
|
||||
with pytest.raises(LookupError):
|
||||
asyncio.run(lookup.resolve_name("Nope", "inventory_types"))
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Market lookup tools and pure aggregation helpers."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from familiar import hubs, market
|
||||
from familiar.server import mcp
|
||||
from conftest import route
|
||||
|
||||
JITA_STATION = 60003760
|
||||
JITA_REGION = 10000002
|
||||
|
||||
|
||||
def _orders():
|
||||
return [
|
||||
# Sells at Jita
|
||||
{
|
||||
"is_buy_order": False,
|
||||
"location_id": JITA_STATION,
|
||||
"price": 5.10,
|
||||
"volume_remain": 100,
|
||||
"range": "station",
|
||||
},
|
||||
{
|
||||
"is_buy_order": False,
|
||||
"location_id": JITA_STATION,
|
||||
"price": 5.05,
|
||||
"volume_remain": 200,
|
||||
"range": "station",
|
||||
},
|
||||
# Buy at Jita
|
||||
{
|
||||
"is_buy_order": True,
|
||||
"location_id": JITA_STATION,
|
||||
"price": 4.90,
|
||||
"volume_remain": 300,
|
||||
"range": "region",
|
||||
},
|
||||
# Order at a different station should be ignored
|
||||
{
|
||||
"is_buy_order": False,
|
||||
"location_id": 999,
|
||||
"price": 1.0,
|
||||
"volume_remain": 1,
|
||||
"range": "station",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_summarize_orders_picks_best_and_ignores_other_stations():
|
||||
s = market.summarize_orders(_orders(), JITA_STATION)
|
||||
assert s["best_sell"] == 5.05
|
||||
assert s["best_buy"] == 4.90
|
||||
assert s["spread"] == 0.15
|
||||
assert s["sell_orders"] == 2
|
||||
assert s["volume_on_sell"] == 300
|
||||
|
||||
|
||||
def test_summarize_orders_handles_empty():
|
||||
s = market.summarize_orders([], JITA_STATION)
|
||||
assert s["best_sell"] is None
|
||||
assert s["spread"] is None
|
||||
|
||||
|
||||
def test_top_orders_sorts_per_side():
|
||||
sells = market.top_orders(_orders(), JITA_STATION, "sell", 10)
|
||||
assert [o["price"] for o in sells] == [5.05, 5.10]
|
||||
buys = market.top_orders(_orders(), JITA_STATION, "buy", 10)
|
||||
assert [o["price"] for o in buys] == [4.90]
|
||||
|
||||
|
||||
def test_get_market_price(mock_esi):
|
||||
fixtures = {
|
||||
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
|
||||
f"/markets/{JITA_REGION}/orders/": _orders(),
|
||||
}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(market.get_market_price("Tritanium", hub="Jita"))
|
||||
assert result["type_id"] == 34
|
||||
assert result["hub"] == "Jita"
|
||||
assert result["best_sell"] == 5.05
|
||||
|
||||
|
||||
def test_get_market_orders_side_parsing(mock_esi):
|
||||
fixtures = {f"/markets/{JITA_REGION}/orders/": _orders()}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(market.get_market_orders(34, hub="Jita", side="buy", limit=5))
|
||||
assert result["side"] == "buy"
|
||||
assert result["orders"][0]["price"] == 4.90
|
||||
|
||||
|
||||
def test_compare_hubs_across_all_regions(mock_esi):
|
||||
# Every region returns the same order book; cheapest sell is the same price
|
||||
# everywhere, so the first hub (Jita) wins the tie.
|
||||
def handler(req):
|
||||
return route(
|
||||
req,
|
||||
{
|
||||
"/orders/": _orders(),
|
||||
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
|
||||
},
|
||||
)
|
||||
|
||||
mock_esi(handler)
|
||||
result = asyncio.run(market.compare_hubs("Tritanium"))
|
||||
assert len(result["hubs"]) == len(hubs.HUBS)
|
||||
assert result["cheapest_sell_hub"] == "Jita"
|
||||
|
||||
|
||||
def test_list_hubs():
|
||||
result = asyncio.run(market.list_hubs())
|
||||
names = {h["name"] for h in result}
|
||||
assert {"Jita", "Amarr", "Dodixie", "Rens", "Hek"} == names
|
||||
|
||||
|
||||
def test_resolve_hub_unknown_raises():
|
||||
with pytest.raises(ValueError):
|
||||
hubs.resolve_hub("Atlantis")
|
||||
|
||||
|
||||
def test_resolve_hub_defaults_to_jita():
|
||||
assert hubs.resolve_hub(None).name == "Jita"
|
||||
|
||||
|
||||
def test_market_tools_are_registered():
|
||||
tools = {t.name for t in asyncio.run(mcp.list_tools())}
|
||||
assert {"list_hubs", "get_market_price", "get_market_orders", "compare_hubs"} <= tools
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Universe lookup tools."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from familiar import universe
|
||||
from familiar.server import mcp
|
||||
from conftest import route
|
||||
|
||||
|
||||
def test_get_item_resolves_name_then_fetches_type(mock_esi):
|
||||
fixtures = {
|
||||
"/universe/ids/": {"inventory_types": [{"id": 34, "name": "Tritanium"}]},
|
||||
"/universe/types/": {
|
||||
"type_id": 34,
|
||||
"name": "Tritanium",
|
||||
"group_id": 18,
|
||||
"volume": 0.01,
|
||||
"packaged_volume": 0.01,
|
||||
"description": "The most common ore type.",
|
||||
},
|
||||
}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(universe.get_item("Tritanium"))
|
||||
assert result["type_id"] == 34
|
||||
assert result["name"] == "Tritanium"
|
||||
assert result["volume"] == 0.01
|
||||
|
||||
|
||||
def test_get_system_rounds_security_and_counts(mock_esi):
|
||||
fixtures = {
|
||||
"/universe/systems/": {
|
||||
"system_id": 30000142,
|
||||
"name": "Jita",
|
||||
"constellation_id": 20000020,
|
||||
"security_status": 0.945913,
|
||||
"security_class": "D",
|
||||
"planets": [{}, {}, {}],
|
||||
"stargates": [1, 2],
|
||||
},
|
||||
}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(universe.get_system(30000142))
|
||||
assert result["security_status"] == 0.95
|
||||
assert result["planets"] == 3
|
||||
assert result["stargates"] == 2
|
||||
|
||||
|
||||
def test_get_station(mock_esi):
|
||||
fixtures = {
|
||||
"/universe/stations/": {
|
||||
"station_id": 60003760,
|
||||
"name": "Jita IV - Moon 4 - Caldari Navy Assembly Plant",
|
||||
"system_id": 30000142,
|
||||
"type_id": 52678,
|
||||
"services": ["market", "repair-facilities"],
|
||||
},
|
||||
}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(universe.get_station(60003760))
|
||||
assert result["system_id"] == 30000142
|
||||
assert "market" in result["services"]
|
||||
|
||||
|
||||
def test_get_region(mock_esi):
|
||||
fixtures = {
|
||||
"/universe/regions/": {
|
||||
"region_id": 10000002,
|
||||
"name": "The Forge",
|
||||
"constellations": [1, 2, 3, 4],
|
||||
"description": "A region.",
|
||||
},
|
||||
}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(universe.get_region(10000002))
|
||||
assert result["name"] == "The Forge"
|
||||
assert result["constellations"] == 4
|
||||
|
||||
|
||||
def test_get_route_counts_jumps(mock_esi):
|
||||
fixtures = {"/route/": [30000142, 30000144, 30002187]}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(universe.get_route(30000142, 30002187, prefer="secure"))
|
||||
assert result["jumps"] == 2
|
||||
assert result["flag"] == "secure"
|
||||
|
||||
|
||||
def test_get_route_rejects_unknown_flag(mock_esi):
|
||||
fixtures = {"/route/": [30000142]}
|
||||
mock_esi(lambda req: route(req, fixtures))
|
||||
result = asyncio.run(universe.get_route(30000142, 30000142, prefer="banana"))
|
||||
assert result["flag"] == "shortest"
|
||||
assert result["jumps"] == 0
|
||||
|
||||
|
||||
def test_resolve_names_passthrough(mock_esi):
|
||||
body = {"systems": [{"id": 30000142, "name": "Jita"}]}
|
||||
mock_esi(lambda req: httpx.Response(200, json=body))
|
||||
result = asyncio.run(universe.resolve_names(["Jita"]))
|
||||
assert result["systems"][0]["id"] == 30000142
|
||||
|
||||
|
||||
def test_universe_tools_are_registered():
|
||||
tools = {t.name for t in asyncio.run(mcp.list_tools())}
|
||||
assert {"get_item", "get_system", "get_station", "get_region", "get_route"} <= tools
|
||||
Reference in new issue
Block a user