refactor: move JS and Python clients under clients/

This commit is contained in:
2026-09-30 22:31:43 +02:00
parent f6a9daa89e
commit b2b815552f
47 changed files with 7 additions and 7 deletions
@@ -0,0 +1,132 @@
import httpx
import pytest
from resolvespec import AsyncFuncSpecClient, FuncSpecClient, ResolveSpecError
from resolvespec.funcspec import build_headers, build_query
from resolvespec.headerspec import decode_header_value
def make(handler, **kw):
return FuncSpecClient("http://localhost:3000", "tok", transport=httpx.MockTransport(handler), **kw)
def capture(status=200, body=None, headers=None):
seen = []
def handler(req):
seen.append(req)
return httpx.Response(status, json=body if body is not None else [], headers=headers)
return seen, handler
def test_filters():
h = build_headers({"filters": [
{"column": "status", "operator": "eq", "value": "active"},
{"column": "age", "operator": "gte", "value": 18},
{"column": "name", "operator": "contains", "value": "x", "logic_operator": "OR"},
{"column": "deleted", "operator": "is_null", "value": None},
{"column": "id", "operator": "in", "value": [1, 2]},
{"column": "p", "operator": "between_inclusive", "value": [1, 5]},
]})
assert h == {
"X-FieldFilter-status": "active",
"X-SearchOp-greaterthanorequal-age": "18",
"X-SearchOr-contains-name": "x",
"X-SearchOp-empty-deleted": "",
"X-SearchOp-in-id": "1,2",
"X-SearchOp-betweeninclusive-p": "1,5",
}
def test_sort_is_sql_not_prefixed():
# server inserts sort verbatim into ORDER BY; "-col" would negate the column
h = build_headers({"sort": [{"column": "name", "direction": "asc"}, {"column": "created_at", "direction": "DESC"}]})
assert h["X-Sort"] == "name ASC,created_at DESC"
def test_misc_options():
h = build_headers({
"search_filters": {"name": "bob"}, "custom_sql_where": "a = 1", "custom_sql_or": "b = 2",
"limit": 5, "offset": 10, "distinct": True, "skip_count": True, "skip_cache": False,
"response_format": "syncfusion",
})
assert h == {
"X-SearchFilter-name": "bob", "X-Custom-SQL-W": "a = 1", "X-Custom-SQL-Or": "b = 2",
"X-Limit": "5", "X-Offset": "10", "X-Distinct": "true", "X-SkipCount": "true",
"X-SkipCache": "false", "X-Syncfusion": "true",
}
def test_ambiguous_values_are_encoded():
h = build_headers({"custom_sql_where": "name = 'café'", "filters": [{"column": "c", "operator": "eq", "value": " pad "}]})
assert h["X-Custom-SQL-W"].startswith("ZIP_")
assert decode_header_value(h["X-Custom-SQL-W"]) == "name = 'café'"
assert decode_header_value(h["X-FieldFilter-c"]) == " pad "
def test_build_query():
q = build_query({"p-id": 5, "flag": True, "ids": [1, 2], "skip": None, "m": "match=ab"})
assert q == {"p-id": "5", "flag": "true", "ids": ["1", "2"], "m": "match=ab"}
def test_query_list_request_and_metadata():
seen, h = capture(206, [{"id": 1}, {"id": 2}], {"content-range": "items 10-12/50"})
with make(h) as c:
res = c.query_list("/api/orders", {"p-status": "open", "id": [1, 2]}, {"limit": 2, "offset": 10})
r = seen[0]
assert r.method == "GET"
assert r.url.path == "/api/orders"
assert r.url.params.multi_items() == [("p-status", "open"), ("id", "1"), ("id", "2")]
assert r.headers["x-limit"] == "2" and r.headers["authorization"] == "Bearer tok"
assert res == {
"success": True,
"data": [{"id": 1}, {"id": 2}],
"metadata": {"total": 50, "count": 2, "filtered": 50, "offset": 10, "limit": 2},
}
def test_query_list_empty_result():
seen, h = capture(200, [], {"content-range": "items 0-0/0"})
with make(h) as c:
assert c.query_list("orders")["metadata"]["total"] == 0
assert seen[0].url.path == "/orders"
def test_query_single_has_no_metadata_and_method():
seen, h = capture(200, {"id": 1})
with make(h) as c:
res = c.query("api/order", method="post")
assert seen[0].method == "POST"
assert res == {"success": True, "data": {"id": 1}}
def test_detail_format_data_passthrough():
body = {"items": [{"a": 1}], "count": "1", "total": "1", "tablename": "/x", "tableprefix": "gsql"}
_, h = capture(200, body, {"content-range": "items 0-1/1"})
with make(h) as c:
assert c.query_list("x", options={"response_format": "detail"})["data"] == body
def test_server_error_shape():
err = {"success": False, "error": {"code": "query_failed", "message": "Failed to retrieve records", "detail": "no such column", "sql": "SELECT"}}
_, h = capture(400, err)
with make(h) as c:
with pytest.raises(ResolveSpecError, match="Failed to retrieve") as ei:
c.query_list("x")
assert ei.value.code == "query_failed" and ei.value.detail == "no such column" and ei.value.status_code == 400
def test_plain_text_panic_error():
with make(lambda r: httpx.Response(500, text="Internal server error: boom")) as c:
with pytest.raises(ResolveSpecError, match="boom"):
c.query("x")
async def test_async():
async def handler(req):
return httpx.Response(200, json=[{"id": 1}], headers={"content-range": "items 0-1/1"})
async with AsyncFuncSpecClient("http://localhost:3000", transport=httpx.MockTransport(handler)) as c:
assert (await c.query_list("x"))["metadata"]["total"] == 1
assert (await c.query("x"))["data"] == [{"id": 1}]
@@ -0,0 +1,236 @@
import json
import httpx
import pytest
from resolvespec import (
AsyncHeaderSpecClient,
HeaderSpecClient,
ResolveSpecError,
build_headers,
decode_header_value,
encode_header_value,
get_headerspec_client,
)
import base64
CFG = dict(base_url="http://localhost:3000", token="tok")
# ---- build_headers (ported from headerspec.test.ts) ----
def test_preload_shared_where():
h = build_headers({"preload": [
{"relation": "Items", "columns": ["id"], "where": "active = true"},
{"relation": "Tags", "where": "active = true"},
]})
assert h["X-Preload"] == "Items:id|Tags"
assert h["X-Preload-Where"] == "active = true"
def test_preload_mixed_where_numbered():
h = build_headers({"preload": [
{"relation": "Items", "where": "a = 1"},
{"relation": "Category"},
{"relation": "Tags", "where": "b = 2"},
]})
assert h["X-Preload"] == "Category"
assert "X-Preload-Where" not in h
assert h["X-Preload-1"] == "Items" and h["X-Preload-1-Where"] == "a = 1"
assert h["X-Preload-2"] == "Tags" and h["X-Preload-2-Where"] == "b = 2"
def test_expand_joins_or_searchcols_advsql():
h = build_headers({
"expand": [{"relation": "Dept", "columns": ["id", "name"]}, {"relation": "Role"}],
"custom_sql_joins": ["LEFT JOIN a ON a.id = b.id", "INNER JOIN c ON c.id = b.cid"],
"custom_sql_or": ["x = 1", "y = 2"],
"search_columns": ["name", "email"],
"advanced_sql": {"total": "a + b"},
})
assert h["X-Expand"] == "Dept:id,name|Role"
assert h["X-Custom-SQL-Join"] == "LEFT JOIN a ON a.id = b.id|INNER JOIN c ON c.id = b.cid"
assert h["X-Custom-SQL-Or"] == "x = 1 OR y = 2"
assert h["X-SearchCols"] == "name,email"
assert h["X-AdvSQL-total"] == "a + b"
def test_flags_pkrow_format():
h = build_headers({
"clean_json": True, "distinct": True, "skip_count": True, "skip_cache": False,
"atomic_transaction": True, "single_record_as_object": False,
"pk_row": "42", "response_format": "detail",
})
assert h["X-Clean-JSON"] == "true"
assert h["X-Distinct"] == "true"
assert h["X-SkipCount"] == "true"
assert h["X-SkipCache"] == "false"
assert h["X-Transaction-Atomic"] == "true"
assert h["X-Single-Record-As-Object"] == "false"
assert h["X-PKRow"] == "42"
assert h["X-DetailApi"] == "true"
def test_spatial_and_vector_filters():
h = build_headers({"filters": [
{"column": "geom", "operator": "st_dwithin", "value": {"geom": "POINT(0 0)", "distance": 5}, "logic_operator": "OR"},
{"column": "emb", "operator": "cosine_within", "value": {"vector": [1, 2], "distance": 0.3}},
]})
assert json.loads(h["X-SpatialFilter-geom"]) == {
"op": "st_dwithin", "value": {"geom": "POINT(0 0)", "distance": 5}, "logic": "or"}
assert json.loads(h["X-VectorFilter-emb"])["op"] == "cosine_within"
def test_vector_search():
h = build_headers({"vector_search": {"column": "emb", "vector": [0.1, 0.2], "metric": "cosine", "as": "dist", "direction": "desc"}})
assert h["X-Vector-Search-emb"] == "cosine"
assert h["X-Vector-Search-Vector"] == "[0.1,0.2]"
assert h["X-Vector-Search-As"] == "dist"
assert h["X-Vector-Search-Dir"] == "desc"
def test_xfiles_zip():
xf = {"tablename": "users", "prefix": "USR", "limit": 10}
h = build_headers({"xfiles": xf})
assert h["X-Files"].startswith("ZIP_")
assert json.loads(decode_header_value(h["X-Files"])) == xf
def test_columns_and_omit():
assert build_headers({"columns": ["id", "name", "email"]})["X-Select-Fields"] == "id,name,email"
assert build_headers({"omit_columns": ["secret", "internal"]})["X-Not-Select-Fields"] == "secret,internal"
def test_filters():
assert build_headers({"filters": [{"column": "status", "operator": "eq", "value": "active"}]})["X-FieldFilter-status"] == "active"
assert build_headers({"filters": [{"column": "age", "operator": "gte", "value": 18}]})["X-SearchOp-greaterthanorequal-age"] == "18"
assert build_headers({"filters": [{"column": "name", "operator": "contains", "value": "test", "logic_operator": "OR"}]})["X-SearchOr-contains-name"] == "test"
assert build_headers({"filters": [{"column": "price", "operator": "between", "value": [10, 100]}]})["X-SearchOp-between-price"] == "10,100"
assert build_headers({"filters": [{"column": "deleted_at", "operator": "is_null", "value": None}]})["X-SearchOp-empty-deleted_at"] == ""
assert build_headers({"filters": [{"column": "id", "operator": "in", "value": [1, 2, 3]}]})["X-SearchOp-in-id"] == "1,2,3"
assert build_headers({"filters": [{"column": "a", "operator": "eq", "value": True}]})["X-FieldFilter-a"] == "true"
def test_sort_pagination_cursor():
h = build_headers({
"sort": [{"column": "name", "direction": "asc"}, {"column": "created_at", "direction": "DESC"}],
"limit": 25, "offset": 0, "cursor_forward": "abc", "cursor_backward": "xyz",
})
assert h["X-Sort"] == "+name,-created_at"
assert h["X-Limit"] == "25" and h["X-Offset"] == "0"
assert h["X-Cursor-Forward"] == "abc" and h["X-Cursor-Backward"] == "xyz"
def test_preload_basic_rownumber_computed_custom():
h = build_headers({
"preload": [{"relation": "Items", "columns": ["id", "name"]}, {"relation": "Category"}],
"fetch_row_number": "42",
"computedColumns": [{"name": "total", "expression": "price * qty"}],
"customOperators": [{"name": "a", "sql": "status = 'active'"}, {"name": "v", "sql": "verified = true"}],
})
assert h["X-Preload"] == "Items:id,name|Category"
assert h["X-Fetch-RowNumber"] == "42"
assert h["X-CQL-SEL-total"] == "price * qty"
assert h["X-Custom-SQL-W"] == "status = 'active' AND verified = true"
def test_empty_options():
assert build_headers({}) == {}
# ---- encode / decode ----
def test_roundtrip():
for s in ("some complex value with spaces & symbols!", "café ☕ 你好"):
enc = encode_header_value(s)
assert enc.startswith("ZIP_")
assert decode_header_value(enc) == s
def test_decode_double_underscore_and_plain():
assert decode_header_value("__" + base64.b64encode(b"hello").decode()) == "hello"
assert decode_header_value("__" + base64.b64encode("café ☕".encode()).decode()) == "café ☕"
assert decode_header_value("plain") == "plain"
def test_decode_nested():
assert decode_header_value(encode_header_value(encode_header_value("x"))) == "x"
# ---- client ----
def make(handler, cls=HeaderSpecClient, **kw):
return cls(**{**CFG, **kw}, transport=httpx.MockTransport(handler))
def test_read_sends_get_with_headers():
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json=[{"id": 1}], headers={"content-range": "0-9/100", "x-limit": "10"})
with make(handler) as c:
res = c.read("public", "users", options={"columns": ["id", "name"], "limit": 10})
r = seen[0]
assert str(r.url) == "http://localhost:3000/public/users"
assert r.method == "GET"
assert r.headers["x-select-fields"] == "id,name"
assert r.headers["x-limit"] == "10"
assert r.headers["authorization"] == "Bearer tok"
assert res["success"] is True
assert res["data"] == [{"id": 1}]
assert res["metadata"] == {"count": 100, "total": 100, "filtered": 100, "offset": 0, "limit": 10}
def test_metadata_defaults_without_content_range():
with make(lambda r: httpx.Response(200, json=[])) as c:
assert c.read("public", "users")["metadata"]["total"] == 0
def test_read_with_id_create_update_delete():
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={})
with make(handler) as c:
c.read("public", "users", "42")
c.create("public", "users", {"name": "Test"})
c.update("public", "users", "1", {"name": "Updated"}, {"filters": [{"column": "active", "operator": "eq", "value": True}]})
c.delete("public", "users", "1")
assert str(seen[0].url) == "http://localhost:3000/public/users/42"
assert seen[1].method == "POST" and json.loads(seen[1].content) == {"name": "Test"}
assert seen[2].method == "PUT" and str(seen[2].url).endswith("/public/users/1")
assert seen[2].headers["x-fieldfilter-active"] == "true"
assert seen[3].method == "DELETE"
def test_error_response():
with make(lambda r: httpx.Response(400, json={"error": {"code": "err", "message": "fail"}})) as c:
with pytest.raises(ResolveSpecError, match="fail") as ei:
c.read("public", "users")
assert ei.value.status_code == 400 and ei.value.code == "err"
def test_error_non_json():
with make(lambda r: httpx.Response(502, text="bad gateway")) as c:
with pytest.raises(ResolveSpecError, match="bad gateway") as ei:
c.read("public", "users")
assert ei.value.status_code == 502
async def test_async_client():
async def handler(req):
return httpx.Response(200, json=[{"id": 1}])
async with AsyncHeaderSpecClient(**CFG, transport=httpx.MockTransport(handler)) as c:
res = await c.read("public", "users", options={"limit": 1})
assert res["data"] == [{"id": 1}]
def test_singleton():
a = get_headerspec_client("http://hs-singleton:3000")
assert a is get_headerspec_client("http://hs-singleton:3000")
assert a is not get_headerspec_client("http://hs-singleton-b:3000")
@@ -0,0 +1,210 @@
import json
import httpx
import pytest
from resolvespec import (
AsyncHeaderSpecClient,
AsyncResolveSpecClient,
HeaderSpecClient,
ResolveSpecClient,
ResolveSpecError,
get_headerspec_client,
get_resolvespec_client,
)
CFG = dict(base_url="http://localhost:3000", token="test-token")
def make(handler, **kw):
return ResolveSpecClient(**{**CFG, **kw}, transport=httpx.MockTransport(handler))
def ok(_req):
return httpx.Response(200, json={"success": True, "data": [{"id": 1}]})
def capture():
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={"success": True, "data": {"id": 1, "name": "Test"}})
return seen, handler
def body(req):
return json.loads(req.content)
def test_read_with_numeric_id():
seen, h = capture()
with make(h) as c:
assert c.read("public", "users", 1)["success"] is True
r = seen[0]
assert str(r.url) == "http://localhost:3000/public/users/1"
assert r.method == "POST"
assert r.headers["authorization"] == "Bearer test-token"
assert r.headers["content-type"] == "application/json"
assert body(r) == {"operation": "read"}
def test_read_array_id_goes_in_body():
seen, h = capture()
with make(h) as c:
c.read("public", "users", ["1", "2"])
assert str(seen[0].url) == "http://localhost:3000/public/users"
assert body(seen[0])["id"] == ["1", "2"]
def test_read_options_passthrough():
seen, h = capture()
opts = {
"columns": ["id", "name"], "omit_columns": ["secret"],
"filters": [{"column": "active", "operator": "eq", "value": True}],
"sort": [{"column": "name", "direction": "asc"}],
"limit": 10, "offset": 0, "cursor_forward": "cursor1", "fetch_row_number": "5",
"customOperators": [{"name": "x", "sql": "a = 1"}],
}
with make(h) as c:
c.read("public", "users", options=opts)
assert body(seen[0])["options"] == opts
def test_create():
seen, h = capture()
with make(h) as c:
res = c.create("public", "users", {"name": "Test"})
assert res["data"]["name"] == "Test"
assert body(seen[0]) == {"operation": "create", "data": {"name": "Test"}}
def test_create_batch():
seen, h = capture()
with make(h) as c:
c.create("public", "users", [{"a": 1}, {"a": 2}])
assert body(seen[0])["data"] == [{"a": 1}, {"a": 2}]
def test_update_with_id_in_url_and_array():
seen, h = capture()
with make(h) as c:
c.update("public", "users", {"name": "X"}, 5)
c.update("public", "users", {"name": "X"}, ["1", "2"])
assert str(seen[0].url).endswith("/public/users/5")
assert body(seen[0]) == {"operation": "update", "data": {"name": "X"}}
assert str(seen[1].url).endswith("/public/users")
assert body(seen[1])["id"] == ["1", "2"]
def test_update_preserves_empty_string_and_null():
seen, h = capture()
with make(h) as c:
c.update("public", "users", {"a": "", "b": None}, 1)
assert body(seen[0])["data"] == {"a": "", "b": None}
def test_delete():
seen, h = capture()
with make(h) as c:
c.delete("public", "users", 1)
assert str(seen[0].url).endswith("/public/users/1")
assert body(seen[0]) == {"operation": "delete"}
def test_get_metadata():
seen, h = capture()
with make(h) as c:
c.get_metadata("public", "users")
assert seen[0].method == "GET"
assert str(seen[0].url) == "http://localhost:3000/public/users"
assert not seen[0].content
def test_error_uses_server_message():
with make(lambda r: httpx.Response(404, json={"success": False, "error": {"code": "not_found", "message": "nope"}})) as c:
with pytest.raises(ResolveSpecError, match="nope") as ei:
c.read("public", "users", 1)
assert ei.value.status_code == 404 and ei.value.code == "not_found"
def test_id_is_url_quoted():
seen, h = capture()
with make(h) as c:
c.read("public", "users", "a/b")
assert str(seen[0].url).endswith("/public/users/a%2Fb")
def test_trailing_slash_base_url():
seen, h = capture()
with make(h, base_url="http://localhost:3000/") as c:
c.read("public", "users")
assert str(seen[0].url) == "http://localhost:3000/public/users"
async def test_async_client():
async def handler(req):
return httpx.Response(200, json={"success": True, "data": [1]})
async with AsyncResolveSpecClient(**CFG, transport=httpx.MockTransport(handler)) as c:
assert (await c.read("public", "users"))["data"] == [1]
assert (await c.create("public", "users", {}))["success"]
assert (await c.update("public", "users", {}, 1))["success"]
assert (await c.delete("public", "users", 1))["success"]
assert (await c.get_metadata("public", "users"))["success"]
# ---- custom headers (ported from custom-headers.test.ts) ----
@pytest.mark.parametrize("cls", [ResolveSpecClient, HeaderSpecClient])
def test_custom_headers_on_every_op_case_insensitive(cls):
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={"success": True, "data": []})
headers = {"X-Tenant": "acme", "authorization": "Basic ignored",
"content-type": "application/custom+json", "x-limit": "99"}
with cls("http://localhost:3000", "tok", headers, transport=httpx.MockTransport(handler)) as c:
c.read("public", "users", options={"limit": 10})
c.create("public", "users", {})
if cls is ResolveSpecClient:
c.update("public", "users", {}, "1")
c.get_metadata("public", "users")
else:
c.update("public", "users", "1", {})
c.delete("public", "users", "1")
for r in seen:
assert r.headers["x-tenant"] == "acme"
assert r.headers["authorization"] == "Bearer tok"
assert r.headers["content-type"] == "application/custom+json"
if cls is HeaderSpecClient:
assert seen[0].headers["x-limit"] == "10"
assert headers["authorization"] == "Basic ignored"
assert headers["x-limit"] == "99"
@pytest.mark.parametrize("cls", [ResolveSpecClient, HeaderSpecClient])
def test_custom_auth_without_token(cls):
seen = []
def handler(req):
seen.append(req)
return httpx.Response(200, json={"success": True, "data": []})
with cls("http://localhost:3000", headers={"Authorization": "Basic custom"}, transport=httpx.MockTransport(handler)) as c:
c.read("public", "users")
assert seen[0].headers["authorization"] == "Basic custom"
@pytest.mark.parametrize("factory", [get_resolvespec_client, get_headerspec_client])
def test_cache_isolation_and_snapshot(factory):
headers = {"X-Tenant": "acme", "X-App": "grid"}
first = factory("http://tenant-cache", "one", headers)
assert factory("http://tenant-cache", "one", {"x-app": "grid", "x-tenant": "acme"}) is first
assert factory("http://tenant-cache", "two", headers) is not first
headers["X-Tenant"] = "other"
assert factory("http://tenant-cache", "one", headers) is not first
assert first.headers["X-Tenant"] == "acme"
@@ -0,0 +1,152 @@
import asyncio
import json
import pytest
from websockets.asyncio.server import serve
from resolvespec import ResolveSpecError, WebSocketClient
class Server:
"""Minimal in-process WebSocketSpec server."""
def __init__(self):
self.received = []
self.conns = set()
self.respond = True
async def handler(self, ws):
self.conns.add(ws)
try:
async for raw in ws:
msg = json.loads(raw)
self.received.append(msg)
if msg["type"] == "ping":
await ws.send(json.dumps({"type": "pong"}))
continue
if not self.respond:
continue
await ws.send(json.dumps(self.reply(msg)))
finally:
self.conns.discard(ws)
def reply(self, msg):
base = {"id": msg["id"], "type": "response", "success": True, "timestamp": "t"}
if msg["type"] == "subscription" and msg["operation"] == "subscribe":
return {**base, "data": {"subscription_id": "sub-1"}}
if msg.get("entity") == "fail":
return {**base, "success": False, "error": {"code": "bad", "message": "boom"}}
return {**base, "data": {"echo": msg.get("operation"), "record_id": msg.get("record_id")}}
@pytest.fixture
async def server():
s = Server()
async with serve(s.handler, "127.0.0.1", 0) as srv:
s.url = "ws://127.0.0.1:%d" % srv.sockets[0].getsockname()[1]
yield s
async def test_operations_and_message_shape(server):
async with WebSocketClient(server.url, reconnect=False) as c:
assert c.state == "connected"
assert await c.read("users", schema="public", record_id="1", limit=5, filters=[{"column": "a", "operator": "eq", "value": 1}]) == {"echo": "read", "record_id": "1"}
await c.create("users", {"n": 1}, schema="public")
await c.update("users", "2", {"n": 2})
await c.delete("users", "3")
await c.meta("users")
m = server.received
assert m[0]["type"] == "request" and m[0]["operation"] == "read"
assert m[0]["schema"] == "public" and m[0]["record_id"] == "1"
assert m[0]["options"] == {"filters": [{"column": "a", "operator": "eq", "value": 1}], "limit": 5}
assert m[1]["data"] == {"n": 1}
assert m[2]["record_id"] == "2"
assert [x["operation"] for x in m] == ["read", "create", "update", "delete", "meta"]
assert "schema" not in m[2]
assert len({x["id"] for x in m}) == 5
async def test_error_response_raises(server):
async with WebSocketClient(server.url, reconnect=False) as c:
with pytest.raises(ResolveSpecError, match="boom") as ei:
await c.read("fail")
assert ei.value.code == "bad"
async def test_request_timeout(server):
server.respond = False
async with WebSocketClient(server.url, reconnect=False, request_timeout=0.1) as c:
with pytest.raises(ResolveSpecError, match="timeout"):
await c.read("users")
assert not c._pending
async def test_not_connected_raises():
c = WebSocketClient("ws://127.0.0.1:1")
with pytest.raises(ResolveSpecError, match="not connected"):
await c.read("users")
async def test_subscribe_notify_unsubscribe(server):
got = asyncio.Queue()
async with WebSocketClient(server.url, reconnect=False) as c:
sid = await c.subscribe("users", got.put, schema="public", filters=[{"column": "a", "operator": "eq", "value": 1}])
assert sid == "sub-1"
assert [s.id for s in c.get_subscriptions()] == ["sub-1"]
assert server.received[0]["operation"] == "subscribe"
assert server.received[0]["options"] == {"filters": [{"column": "a", "operator": "eq", "value": 1}]}
for ws in server.conns:
await ws.send(json.dumps({"type": "notification", "operation": "create", "subscription_id": "sub-1",
"entity": "users", "data": {"id": 9}, "timestamp": "t"}))
n = await asyncio.wait_for(got.get(), 2)
assert n["data"] == {"id": 9}
await c.unsubscribe("sub-1")
assert c.get_subscriptions() == []
assert server.received[-1] == {**server.received[-1], "operation": "unsubscribe", "subscription_id": "sub-1"}
async def test_events_and_heartbeat(server):
events = []
c = WebSocketClient(server.url, reconnect=False, heartbeat_interval=0.05)
c.on("connect", lambda: events.append("connect"))
c.on("state_change", lambda s: events.append(s))
c.on("message", lambda m: events.append(("msg", m["type"])))
await c.connect()
await asyncio.sleep(0.2)
await c.close()
assert events[:3] == ["connecting", "connected", "connect"]
assert ("msg", "pong") in events
assert events[-1] == "disconnected"
assert any(m["type"] == "ping" for m in server.received)
with pytest.raises(ValueError):
c.on("bogus", lambda: None)
async def test_reconnect_after_server_drop(server):
states = []
c = WebSocketClient(server.url, reconnect=True, reconnect_interval=0.05)
c.on("state_change", states.append)
await c.connect()
for ws in list(server.conns):
await ws.close()
for _ in range(100):
if states.count("connected") >= 2:
break
await asyncio.sleep(0.05)
assert "reconnecting" in states
assert c.is_connected()
assert (await c.read("users"))["echo"] == "read"
await c.close()
async def test_pending_requests_fail_on_disconnect(server):
server.respond = False
c = WebSocketClient(server.url, reconnect=False)
await c.connect()
task = asyncio.create_task(c.read("users"))
await asyncio.sleep(0.05)
for ws in list(server.conns):
await ws.close()
with pytest.raises(ResolveSpecError, match="disconnected"):
await asyncio.wait_for(task, 2)
await c.close()