mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 12:31:59 +00:00
refactor: move JS and Python clients under clients/
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user