252 lines
8.2 KiB
Python
252 lines
8.2 KiB
Python
"""Unit tests for the X Chat API client (httpx MockTransport — no network).
|
|
|
|
Covers the auth lifecycle the adapter depends on: reactive 401 refresh,
|
|
refresh-token rotation persistence, proactive expiry refresh, and 429
|
|
rate-limit surfacing.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any, Dict, List
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from plugins.platforms.xchat.api import (
|
|
_EVENT_FIELDS,
|
|
XChatApi,
|
|
XChatApiError,
|
|
XChatRateLimited,
|
|
)
|
|
|
|
# Documented chat_message_event.fields values — the endpoint 400s on
|
|
# anything else (created_at_msec was the original bug).
|
|
_VALID_EVENT_FIELDS = {
|
|
"id",
|
|
"conversation_id",
|
|
"conversation_token",
|
|
"created_at",
|
|
"encoded_event",
|
|
"is_trusted",
|
|
"previous_id",
|
|
"sender_id",
|
|
"message_event_signature",
|
|
}
|
|
|
|
|
|
def test_event_fields_are_all_documented():
|
|
requested = set(_EVENT_FIELDS.split(","))
|
|
unknown = requested - _VALID_EVENT_FIELDS
|
|
assert not unknown, f"undocumented event fields would 400 every poll: {unknown}"
|
|
|
|
|
|
def _client(handler) -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_401_triggers_refresh_and_retry():
|
|
calls: List[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
calls.append(request)
|
|
if request.url.path == "/2/oauth2/token":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"access_token": "new-access",
|
|
"refresh_token": "new-refresh",
|
|
"expires_in": 7200,
|
|
},
|
|
)
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth == "Bearer stale":
|
|
return httpx.Response(401, json={"title": "Unauthorized"})
|
|
return httpx.Response(200, json={"data": {"id": "42"}})
|
|
|
|
persisted: Dict[str, str] = {}
|
|
|
|
async def on_refresh(access: str, refresh: str) -> None:
|
|
persisted["access"] = access
|
|
persisted["refresh"] = refresh
|
|
|
|
api = XChatApi(
|
|
"stale",
|
|
refresh_token="old-refresh",
|
|
client_id="cid",
|
|
on_token_refresh=on_refresh,
|
|
client=_client(handler),
|
|
)
|
|
out = await api.get_my_user()
|
|
assert out == {"id": "42"}
|
|
|
|
# 401 → token endpoint → retried request with the new bearer.
|
|
paths = [c.url.path for c in calls]
|
|
assert paths == ["/2/users/me", "/2/oauth2/token", "/2/users/me"]
|
|
assert calls[-1].headers["Authorization"] == "Bearer new-access"
|
|
# Rotated pair persisted via the callback.
|
|
assert persisted == {"access": "new-access", "refresh": "new-refresh"}
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_401_without_refresh_config_raises():
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(401, json={"title": "Unauthorized"})
|
|
|
|
api = XChatApi("stale", client=_client(handler))
|
|
with pytest.raises(XChatApiError) as ei:
|
|
await api.get_my_user()
|
|
assert ei.value.status == 401
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_401_refresh_retries_once_only():
|
|
"""A still-401 response after refresh surfaces the error instead of
|
|
looping the refresh grant forever."""
|
|
calls: List[str] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
calls.append(request.url.path)
|
|
if request.url.path == "/2/oauth2/token":
|
|
return httpx.Response(200, json={"access_token": "still-bad"})
|
|
return httpx.Response(401, json={"title": "Unauthorized"})
|
|
|
|
api = XChatApi(
|
|
"stale", refresh_token="r", client_id="cid", client=_client(handler)
|
|
)
|
|
with pytest.raises(XChatApiError) as ei:
|
|
await api.get_my_user()
|
|
assert ei.value.status == 401
|
|
assert calls.count("/2/oauth2/token") == 1
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proactive_refresh_near_expiry():
|
|
calls: List[str] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
calls.append(request.url.path)
|
|
if request.url.path == "/2/oauth2/token":
|
|
return httpx.Response(
|
|
200, json={"access_token": "fresh", "expires_in": 7200}
|
|
)
|
|
return httpx.Response(200, json={"data": {"id": "42"}})
|
|
|
|
api = XChatApi(
|
|
"old",
|
|
refresh_token="r",
|
|
client_id="cid",
|
|
token_expires_at=time.time() + 10, # inside the refresh slack window
|
|
client=_client(handler),
|
|
)
|
|
await api.get_my_user()
|
|
assert calls[0] == "/2/oauth2/token" # refreshed BEFORE the request
|
|
assert calls[1] == "/2/users/me"
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_429_raises_rate_limited_with_reset():
|
|
reset = int(time.time()) + 600
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
429,
|
|
headers={"x-rate-limit-reset": str(reset)},
|
|
json={"title": "Too Many Requests"},
|
|
)
|
|
|
|
api = XChatApi("tok", client=_client(handler))
|
|
with pytest.raises(XChatRateLimited) as ei:
|
|
await api.add_public_key("42", {"public_key": {}})
|
|
assert ei.value.status == 429
|
|
assert ei.value.reset_epoch == reset
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_events_requests_documented_fields_and_hyphenates_id():
|
|
seen: Dict[str, Any] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["path"] = request.url.path
|
|
seen["fields"] = request.url.params.get("chat_message_event.fields")
|
|
return httpx.Response(200, json={"data": []})
|
|
|
|
api = XChatApi("tok", client=_client(handler))
|
|
await api.get_events("111:999")
|
|
# Colon-form conversation ids are hyphenated for the URL path.
|
|
assert seen["path"] == "/2/chat/conversations/111-999/events"
|
|
requested = set((seen["fields"] or "").split(","))
|
|
assert requested and requested <= _VALID_EVENT_FIELDS
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_upload_three_step_flow():
|
|
import base64 as _b64
|
|
|
|
calls: List[Dict[str, Any]] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
import json as _json
|
|
|
|
body = _json.loads(request.content) if request.content else {}
|
|
calls.append({"path": request.url.path, "body": body})
|
|
if request.url.path == "/2/chat/media/upload/initialize":
|
|
assert body["conversation_id"] == "111-999"
|
|
return httpx.Response(
|
|
200,
|
|
json={"data": {"session_id": "sess1", "media_hash_key": "mhk9",
|
|
"conversation_id": "111-999"}},
|
|
)
|
|
return httpx.Response(200, json={"data": {}})
|
|
|
|
api = XChatApi("tok", client=_client(handler))
|
|
blob = b"E" * (3 * 1024) # 3 chunks at 1KB chunk size
|
|
out = await api.media_upload("111:999", blob, chunk_size=1024)
|
|
assert out == "mhk9"
|
|
|
|
paths = [c["path"] for c in calls]
|
|
assert paths[0] == "/2/chat/media/upload/initialize"
|
|
appends = [c for c in calls if c["path"].endswith("/append")]
|
|
assert len(appends) == 3
|
|
assert [a["body"]["segment_index"] for a in appends] == [0, 1, 2]
|
|
reassembled = b"".join(_b64.b64decode(a["body"]["media"]) for a in appends)
|
|
assert reassembled == blob
|
|
assert paths[-1] == "/2/chat/media/upload/sess1/finalize"
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_download_returns_raw_bytes():
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/2/chat/media/111-999/mhk9"
|
|
return httpx.Response(200, content=b"\x00ciphertext\xff")
|
|
|
|
api = XChatApi("tok", client=_client(handler))
|
|
blob = await api.media_download("111:999", "mhk9")
|
|
assert blob == b"\x00ciphertext\xff"
|
|
await api.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_read_posts_sequence_id():
|
|
seen: Dict[str, Any] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
import json as _json
|
|
|
|
seen["path"] = request.url.path
|
|
seen["body"] = _json.loads(request.content)
|
|
return httpx.Response(200, json={"data": {}})
|
|
|
|
api = XChatApi("tok", client=_client(handler))
|
|
await api.mark_read("111:999", "sq42")
|
|
assert seen["path"] == "/2/chat/conversations/111-999/read"
|
|
assert seen["body"] == {"seen_until_sequence_id": "sq42"}
|
|
await api.aclose()
|