diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff0a88..1d0ff7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to `overlay-social` (Python). Mirrors `@overlay-social/sdk` (TypeScript) one-to-one. +## Unreleased + +### Fixed +- `get_feed(bbox=...)` sent the box longitude first, but `/v1/feed` reads it + latitude first, so the box selected the wrong area (usually none). The + argument is still `(west, south, east, north)`; it is now sent as + `south,west,north,east`, the same fix as `@overlay-social/sdk`. Callers that + worked around this by passing latitude first should switch to the + documented order. A `bbox` that is not four values raises `ValueError` + instead of being forwarded as is. + ## 0.3.0 — 2026-07-29 ### Added @@ -13,8 +24,9 @@ All notable changes to `overlay-social` (Python). Mirrors `numbers` when a key owns more than one). Additive: comes alongside `author`, never replaces it, and is absent (not `None`) when the author has no number. `membership_proof: "none"` is explicit that the value is - read from Zanaadu's registry, not verified against their Merkle tree. - See `peck-overlay-schema/ZANAADU_POSTANCHOR_FORMAT.md` §13. + read from Zanaadu's registry, not verified against their Merkle tree. The + number is derived from Zanaadu's registry transactions (a registration + advances a counter; entries can change owner through their marketplace). ## 0.2.0 — 2026-06-12 diff --git a/README.md b/README.md index 80df88c..04934e8 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ notifs = overlay.get_notifications("", limit=50) overlay.get_follows("
") # follower/following counts + rows overlay.get_blocks("
") # OUTGOING block/mute list only overlay.get_feed(near={"lat": 59.94, "lng": 10.76}, radius_km=2) # geo -overlay.get_feed(bbox=(10.5, 59.8, 11.0, 60.1)) # bounding box +overlay.get_feed(bbox=(10.5, 59.8, 11.0, 60.1)) # bounding box (west, south, east, north) overlay.verify_root("tm_social-content") # on-chain anchor vs live state-root ``` diff --git a/overlay_social/client.py b/overlay_social/client.py index 456e74a..62df513 100644 --- a/overlay_social/client.py +++ b/overlay_social/client.py @@ -6,7 +6,7 @@ REST facade that actually runs today (verified against overlay.peck.to), NOT the BRC-24 ``peck-schema`` lookup (that lookup is a no-op). -Semantics mirror @overlay-social/sdk and peck-web/overlay_client.py: +Semantics mirror @overlay-social/sdk: * ``resolve_identities`` returns ``{}`` on ANY error and omits keys without a canonical ProfileToken, so a feed UI can enrich defensively and NEVER break. * single-item lookups return ``None`` for missing/invalid (404/400/empty). @@ -66,7 +66,10 @@ def put(k: str, v: Any) -> None: put("author", params.get("author")) put("order", params.get("order", "desc")) put("before", params.get("before")) - # Geo: near={"lat":..,"lng":..} (+ radius_km) → haversine; bbox=(w,s,e,n). + # Geo: near={"lat":..,"lng":..} (+ radius_km) → haversine, or + # bbox=(west, south, east, north) (longitude first, the GeoJSON order). + # On the wire the overlay reads latitude first: bbox=south,west,north,east. + # `near` wins when both are given. near = params.get("near") if near: lat = near.get("lat") if isinstance(near, dict) else near[0] @@ -74,7 +77,11 @@ def put(k: str, v: Any) -> None: put("near", f"{lat},{lng}") put("radius_km", params.get("radius_km")) elif params.get("bbox"): - put("bbox", ",".join(str(x) for x in params["bbox"])) + try: + west, south, east, north = params["bbox"] + except (TypeError, ValueError): + raise ValueError("bbox must be (west, south, east, north)") from None + put("bbox", f"{south},{west},{north},{east}") return out diff --git a/overlay_social/models.py b/overlay_social/models.py index 716134e..b820fae 100644 --- a/overlay_social/models.py +++ b/overlay_social/models.py @@ -35,8 +35,10 @@ class SourceHandle(_SourceHandleRequired, total=False): May appear at ``PeckRow["source_handle"]``. Today the only source is Zanaadu: it sells on-chain "user numbers" (an on-chain collectibles registry), and rows with ``app == "zanaadu"`` carry the holder's number - here. See ``peck-overlay-schema/ZANAADU_POSTANCHOR_FORMAT.md`` §13 for - the full derivation and its proof gap. + here. The number is derived from Zanaadu's own registry transactions: a + registration advances a counter and the new entry takes the counter's + previous value, and an entry can later change owner through Zanaadu's + marketplace, so it is not always readable from a single transaction. ``namespace`` is mandatory and comes first so the value can never be shown bare and mistaken for a peck handle — render it qualified, e.g. @@ -57,7 +59,7 @@ class SourceHandle(_SourceHandleRequired, total=False): - ``numbers`` (all numbers this key owns at the source, ascending) is only present when there is more than one; ``value``/``number`` are then the lowest. Tie-break for "which number is primary" when a key - owns several is NOT proven on-chain (§13.7) — lowest was chosen for + owns several is NOT proven on-chain — lowest was chosen for stability over time, not because it is confirmed canonical. """ diff --git a/tests/test_feed_query.py b/tests/test_feed_query.py new file mode 100644 index 0000000..208993e --- /dev/null +++ b/tests/test_feed_query.py @@ -0,0 +1,89 @@ +"""Offline tests for the /v1/feed query the clients send. + +No network: the clients are given an ``httpx`` mock transport that records the +request, so these run everywhere (unlike the live tests in ``test_client.py``). +""" + +from __future__ import annotations + +import httpx +import pytest + +from overlay_social import AsyncOverlayClient, OverlayClient + +_EMPTY_FEED = {"status": "ok", "data": [], "limit": 50, "offset": 0} + + +def _recording_handler(calls: list[httpx.Request]): + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + return httpx.Response(200, json=_EMPTY_FEED) + + return handler + + +def _sync_client(calls: list[httpx.Request]) -> OverlayClient: + transport = httpx.MockTransport(_recording_handler(calls)) + return OverlayClient(base_url="https://overlay.test", client=httpx.Client(transport=transport)) + + +def _async_client(calls: list[httpx.Request]) -> AsyncOverlayClient: + transport = httpx.MockTransport(_recording_handler(calls)) + return AsyncOverlayClient( + base_url="https://overlay.test", client=httpx.AsyncClient(transport=transport) + ) + + +# Oslo: longitude about 10.7, latitude about 59.9. +# The argument is (west, south, east, north), longitude first (GeoJSON order). +OSLO_BBOX = (10.6, 59.8, 10.9, 60.0) +# The overlay reads minLat,minLng,maxLat,maxLng. +OSLO_WIRE = "59.8,10.6,60.0,10.9" + + +def test_bbox_is_sent_latitude_first_sync() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(bbox=OSLO_BBOX) + assert calls[0].url.params["bbox"] == OSLO_WIRE + + +@pytest.mark.asyncio +async def test_bbox_is_sent_latitude_first_async() -> None: + calls: list[httpx.Request] = [] + async with _async_client(calls) as o: + await o.get_feed(bbox=OSLO_BBOX) + assert calls[0].url.params["bbox"] == OSLO_WIRE + + +def test_bbox_accepts_a_list_and_integers() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(bbox=[10, 59, 11, 60]) + assert calls[0].url.params["bbox"] == "59,10,60,11" + + +def test_near_wins_over_bbox_and_keeps_its_wire_format() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(near={"lat": 59.9, "lng": 10.7}, radius_km=3, bbox=(1, 2, 3, 4)) + params = calls[0].url.params + assert params["near"] == "59.9,10.7" + assert params["radius_km"] == "3" + assert "bbox" not in params + + +def test_no_bbox_param_without_a_geo_filter() -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + o.get_feed(limit=5) + assert "bbox" not in calls[0].url.params + + +@pytest.mark.parametrize("bad", [(1, 2, 3), (1, 2, 3, 4, 5), 7]) +def test_bbox_with_the_wrong_shape_is_rejected_before_any_request(bad: object) -> None: + calls: list[httpx.Request] = [] + with _sync_client(calls) as o: + with pytest.raises(ValueError, match="west, south, east, north"): + o.get_feed(bbox=bad) + assert calls == []