tigrbl-tests 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tests/__init__.py +0 -0
  2. tests/conftest.py +285 -0
  3. tests/i9n/__init__.py +0 -0
  4. tests/i9n/test_acronym_route_name.py +16 -0
  5. tests/i9n/test_allow_anon.py +239 -0
  6. tests/i9n/test_apikey_generation.py +47 -0
  7. tests/i9n/test_authn_provider_integration.py +67 -0
  8. tests/i9n/test_bindings_integration.py +108 -0
  9. tests/i9n/test_bindings_modules.py +149 -0
  10. tests/i9n/test_bulk_docs_client.py +99 -0
  11. tests/i9n/test_core_access.py +164 -0
  12. tests/i9n/test_error_mappings.py +360 -0
  13. tests/i9n/test_field_spec_effects.py +117 -0
  14. tests/i9n/test_header_io_uvicorn.py +71 -0
  15. tests/i9n/test_healthz_methodz_hookz.py +203 -0
  16. tests/i9n/test_hook_ctx_v3_i9n.py +392 -0
  17. tests/i9n/test_hook_lifecycle.py +452 -0
  18. tests/i9n/test_iospec_attributes.py +368 -0
  19. tests/i9n/test_iospec_integration.py +181 -0
  20. tests/i9n/test_key_digest_uvicorn.py +151 -0
  21. tests/i9n/test_list_filters_optional.py +20 -0
  22. tests/i9n/test_mixins.py +534 -0
  23. tests/i9n/test_nested_path_schema_and_rpc.py +34 -0
  24. tests/i9n/test_nested_routing_depth.py +118 -0
  25. tests/i9n/test_op_ctx_alias_examples.py +62 -0
  26. tests/i9n/test_op_ctx_behavior.py +395 -0
  27. tests/i9n/test_op_ctx_core_crud_order.py +219 -0
  28. tests/i9n/test_openapi_clear_response_schema.py +35 -0
  29. tests/i9n/test_openapi_schema_examples_presence.py +81 -0
  30. tests/i9n/test_opspec_effects_i9n_test.py +193 -0
  31. tests/i9n/test_owner_tenant_policy.py +173 -0
  32. tests/i9n/test_request_extras.py +74 -0
  33. tests/i9n/test_request_extras_provider.py +27 -0
  34. tests/i9n/test_response_extras_provider.py +22 -0
  35. tests/i9n/test_rest_fallback_serialization.py +66 -0
  36. tests/i9n/test_rest_row_serialization.py +81 -0
  37. tests/i9n/test_rest_rpc_parity_v3.py +0 -0
  38. tests/i9n/test_row_result_serialization.py +84 -0
  39. tests/i9n/test_schema.py +45 -0
  40. tests/i9n/test_schema_ctx_attributes_integration.py +178 -0
  41. tests/i9n/test_schema_ctx_op_ctx_integration.py +88 -0
  42. tests/i9n/test_schema_ctx_spec_integration.py +209 -0
  43. tests/i9n/test_sqlite_attachments.py +36 -0
  44. tests/i9n/test_storage_spec_integration.py +126 -0
  45. tests/i9n/test_symmetry_parity.py +26 -0
  46. tests/i9n/test_v3_bulk_rest_endpoints.py +120 -0
  47. tests/i9n/test_v3_default_rest_ops.py +145 -0
  48. tests/i9n/test_v3_default_rpc_ops.py +234 -0
  49. tests/i9n/test_v3_opspec_attributes.py +272 -0
  50. tests/i9n/test_verb_alias_policy.py +57 -0
  51. tests/i9n/uvicorn_utils.py +43 -0
  52. tests/perf/__init__.py +0 -0
  53. tests/perf/test_collect_caching.py +42 -0
  54. tests/perf/test_hookz_performance.py +89 -0
  55. tests/perf/test_methodz_performance.py +99 -0
  56. tests/unit/__init__.py +0 -0
  57. tests/unit/decorators/test_alias_ctx_bindings.py +34 -0
  58. tests/unit/decorators/test_engine_ctx_bindings.py +57 -0
  59. tests/unit/decorators/test_hook_ctx_bindings.py +53 -0
  60. tests/unit/decorators/test_op_alias_bindings.py +39 -0
  61. tests/unit/decorators/test_op_ctx_bindings.py +82 -0
  62. tests/unit/decorators/test_response_ctx_bindings.py +32 -0
  63. tests/unit/decorators/test_schema_ctx_bindings.py +39 -0
  64. tests/unit/response_utils.py +142 -0
  65. tests/unit/runtime/atoms/test_emit_paired_post.py +27 -0
  66. tests/unit/runtime/atoms/test_emit_paired_pre.py +38 -0
  67. tests/unit/runtime/atoms/test_emit_readtime_alias.py +41 -0
  68. tests/unit/runtime/atoms/test_out_masking.py +76 -0
  69. tests/unit/runtime/atoms/test_refresh_demand.py +43 -0
  70. tests/unit/runtime/atoms/test_resolve_assemble.py +45 -0
  71. tests/unit/runtime/atoms/test_resolve_paired_gen.py +65 -0
  72. tests/unit/runtime/atoms/test_schema_collect_in.py +44 -0
  73. tests/unit/runtime/atoms/test_schema_collect_out.py +43 -0
  74. tests/unit/runtime/atoms/test_storage_to_stored.py +45 -0
  75. tests/unit/runtime/atoms/test_wire_build_in.py +13 -0
  76. tests/unit/runtime/atoms/test_wire_build_out.py +40 -0
  77. tests/unit/runtime/atoms/test_wire_dump.py +14 -0
  78. tests/unit/runtime/atoms/test_wire_validate_in.py +69 -0
  79. tests/unit/runtime/test_events_phases.py +14 -0
  80. tests/unit/test_acol_vcol_knobs.py +96 -0
  81. tests/unit/test_alias_ctx_op_alias_attributes.py +75 -0
  82. tests/unit/test_alias_ctx_op_attributes.py +61 -0
  83. tests/unit/test_api_level_set_auth.py +25 -0
  84. tests/unit/test_app_model_defaults.py +28 -0
  85. tests/unit/test_app_reexport.py +6 -0
  86. tests/unit/test_base_facade_initialize.py +71 -0
  87. tests/unit/test_build_list_params_spec_model.py +33 -0
  88. tests/unit/test_bulk_body_annotation.py +23 -0
  89. tests/unit/test_bulk_response_schema.py +153 -0
  90. tests/unit/test_colspec_map_isolation.py +19 -0
  91. tests/unit/test_column_collect_mixins.py +21 -0
  92. tests/unit/test_column_rest_rpc_results.py +298 -0
  93. tests/unit/test_column_table_orm_binding.py +51 -0
  94. tests/unit/test_config_dataclass_none.py +12 -0
  95. tests/unit/test_core_crud_bulk_ops.py +160 -0
  96. tests/unit/test_core_crud_default_ops.py +174 -0
  97. tests/unit/test_core_crud_methods.py +337 -0
  98. tests/unit/test_core_wrap_memoization.py +67 -0
  99. tests/unit/test_db_dependency.py +19 -0
  100. tests/unit/test_decorator_and_collect.py +47 -0
  101. tests/unit/test_default_tags.py +20 -0
  102. tests/unit/test_engine_spec_and_shortcuts.py +84 -0
  103. tests/unit/test_engine_usage_levels.py +36 -0
  104. tests/unit/test_field_spec_attrs.py +95 -0
  105. tests/unit/test_file_response.py +148 -0
  106. tests/unit/test_handler_step_qualname.py +30 -0
  107. tests/unit/test_hook_ctx_attributes.py +33 -0
  108. tests/unit/test_hook_ctx_binding.py +55 -0
  109. tests/unit/test_hookz_empty_phase.py +37 -0
  110. tests/unit/test_hybrid_session_run_sync.py +18 -0
  111. tests/unit/test_in_tx.py +16 -0
  112. tests/unit/test_include_models_base_prefix.py +29 -0
  113. tests/unit/test_initialize_cross_ddl.py +26 -0
  114. tests/unit/test_io_spec_attributes.py +171 -0
  115. tests/unit/test_iospec_attributes.py +90 -0
  116. tests/unit/test_iospec_effects.py +173 -0
  117. tests/unit/test_jsonrpc_id_example.py +9 -0
  118. tests/unit/test_jsonrpc_router_default_tag.py +10 -0
  119. tests/unit/test_kernel_invoke_ctx.py +14 -0
  120. tests/unit/test_kernel_opview_on_demand.py +42 -0
  121. tests/unit/test_kernel_plan_labels.py +40 -0
  122. tests/unit/test_kernelz_endpoint.py +65 -0
  123. tests/unit/test_make_column_shortcuts.py +80 -0
  124. tests/unit/test_mixins_sqlalchemy.py +13 -0
  125. tests/unit/test_op_alias.py +70 -0
  126. tests/unit/test_op_class_engine_binding.py +38 -0
  127. tests/unit/test_op_ctx_arity_paths.py +83 -0
  128. tests/unit/test_op_ctx_attributes.py +147 -0
  129. tests/unit/test_op_ctx_core_crud_integration.py +376 -0
  130. tests/unit/test_op_ctx_dynamic_attach.py +19 -0
  131. tests/unit/test_op_ctx_persist_options.py +75 -0
  132. tests/unit/test_opspec_effects.py +153 -0
  133. tests/unit/test_postgres_engine_errors.py +17 -0
  134. tests/unit/test_postgres_env_vars.py +17 -0
  135. tests/unit/test_relationship_alias_cols.py +98 -0
  136. tests/unit/test_request_body_schema.py +54 -0
  137. tests/unit/test_request_response_examples.py +169 -0
  138. tests/unit/test_resolver_precedence.py +49 -0
  139. tests/unit/test_response_alias_table_rpc.py +40 -0
  140. tests/unit/test_response_ctx_precedence.py +62 -0
  141. tests/unit/test_response_diagnostics_kernelz.py +81 -0
  142. tests/unit/test_response_html_jinja_behavior.py +116 -0
  143. tests/unit/test_response_parity.py +20 -0
  144. tests/unit/test_response_rest.py +91 -0
  145. tests/unit/test_response_rpc.py +88 -0
  146. tests/unit/test_response_template.py +30 -0
  147. tests/unit/test_response_uuid.py +58 -0
  148. tests/unit/test_rest_all_default_op_verbs.py +58 -0
  149. tests/unit/test_rest_bulk_delete_suppresses_clear.py +25 -0
  150. tests/unit/test_rest_no_schema_jsonable.py +68 -0
  151. tests/unit/test_rest_operation_id_uniqueness.py +36 -0
  152. tests/unit/test_rest_rpc_parity_default_ops.py +86 -0
  153. tests/unit/test_rest_rpc_prefixes.py +40 -0
  154. tests/unit/test_rest_rpc_symmetry.py +151 -0
  155. tests/unit/test_rpc_all_default_op_verbs.py +224 -0
  156. tests/unit/test_rpc_default_ops.py +111 -0
  157. tests/unit/test_schema_ctx_attributes.py +96 -0
  158. tests/unit/test_schema_ctx_plain_class.py +34 -0
  159. tests/unit/test_schema_spec_presence.py +36 -0
  160. tests/unit/test_schemas_binding.py +29 -0
  161. tests/unit/test_security_per_route.py +43 -0
  162. tests/unit/test_should_wire_canonical.py +62 -0
  163. tests/unit/test_spec_api.py +50 -0
  164. tests/unit/test_spec_app.py +28 -0
  165. tests/unit/test_spec_column.py +24 -0
  166. tests/unit/test_spec_engine.py +61 -0
  167. tests/unit/test_spec_field.py +16 -0
  168. tests/unit/test_spec_hook.py +35 -0
  169. tests/unit/test_spec_io.py +29 -0
  170. tests/unit/test_spec_op.py +31 -0
  171. tests/unit/test_spec_storage.py +21 -0
  172. tests/unit/test_spec_table.py +21 -0
  173. tests/unit/test_sqlite_attachments.py +57 -0
  174. tests/unit/test_storage_spec_attributes.py +78 -0
  175. tests/unit/test_sys_handler_crud.py +86 -0
  176. tests/unit/test_sys_run_rollback.py +42 -0
  177. tests/unit/test_sys_tx_async_begin.py +45 -0
  178. tests/unit/test_sys_tx_begin.py +49 -0
  179. tests/unit/test_sys_tx_commit.py +59 -0
  180. tests/unit/test_table_base_exports.py +22 -0
  181. tests/unit/test_table_collect_spec.py +41 -0
  182. tests/unit/test_table_columns_namespace.py +21 -0
  183. tests/unit/test_v3_favicon_endpoint.py +17 -0
  184. tests/unit/test_v3_healthz_endpoint.py +39 -0
  185. tests/unit/test_v3_op_alias.py +88 -0
  186. tests/unit/test_v3_op_ctx_attributes.py +99 -0
  187. tests/unit/test_v3_schemas_and_decorators.py +114 -0
  188. tests/unit/test_v3_storage_spec_attributes.py +249 -0
  189. tigrbl_tests-0.3.0.dist-info/METADATA +103 -0
  190. tigrbl_tests-0.3.0.dist-info/RECORD +192 -0
  191. tigrbl_tests-0.3.0.dist-info/WHEEL +4 -0
  192. tigrbl_tests-0.3.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+ from types import SimpleNamespace
3
+ import pytest
4
+ from tigrbl.types import App
5
+ from fastapi.testclient import TestClient
6
+
7
+ from tigrbl.bindings import rpc_call
8
+ from .response_utils import build_ping_model
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_response_rest_rpc_parity():
13
+ Widget = build_ping_model()
14
+ app = App()
15
+ app.include_router(Widget.rest.router)
16
+ client = TestClient(app)
17
+ rest_result = client.post("/widget/ping", json={}).json()
18
+ api = SimpleNamespace(models={"Widget": Widget})
19
+ rpc_result = await rpc_call(api, Widget, "ping", {}, db=SimpleNamespace())
20
+ assert rest_result == rpc_result == {"pong": True}
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+ import pytest
3
+ from tigrbl.types import App
4
+ from fastapi.testclient import TestClient
5
+
6
+ from .response_utils import (
7
+ RESPONSE_KINDS,
8
+ build_model_for_response,
9
+ build_model_for_response_non_alias,
10
+ build_ping_model,
11
+ build_model_for_jinja_response,
12
+ )
13
+
14
+
15
+ def test_response_rest_call():
16
+ Widget = build_ping_model()
17
+ app = App()
18
+ app.include_router(Widget.rest.router)
19
+ client = TestClient(app)
20
+ r = client.post("/widget/ping", json={})
21
+ assert r.status_code == 200
22
+ assert r.json() == {"pong": True}
23
+
24
+
25
+ @pytest.mark.parametrize("kind", RESPONSE_KINDS)
26
+ def test_response_rest_alias_table(kind, tmp_path):
27
+ Widget, file_path = build_model_for_response(kind, tmp_path)
28
+ app = App()
29
+ app.include_router(Widget.rest.router)
30
+ client = TestClient(app)
31
+ kwargs = {"json": {}}
32
+ if kind == "redirect":
33
+ kwargs["follow_redirects"] = False
34
+ r = client.post("/widget/download", **kwargs)
35
+ if kind == "auto":
36
+ assert r.json() == {"data": {"pong": True}, "ok": True}
37
+ elif kind == "json":
38
+ assert r.json() == {"pong": True}
39
+ elif kind == "html":
40
+ assert r.text == "<h1>pong</h1>"
41
+ elif kind == "text":
42
+ assert r.text == "pong"
43
+ elif kind == "file":
44
+ assert r.content == file_path.read_bytes()
45
+ elif kind == "stream":
46
+ assert r.content == b"pong"
47
+ elif kind == "redirect":
48
+ assert r.status_code == 307
49
+ assert r.headers["location"] == "/redirected"
50
+ return
51
+ assert r.status_code == 200
52
+
53
+
54
+ @pytest.mark.parametrize("kind", RESPONSE_KINDS)
55
+ def test_response_rest_non_alias_table(kind, tmp_path):
56
+ Widget, file_path = build_model_for_response_non_alias(kind, tmp_path)
57
+ app = App()
58
+ app.include_router(Widget.rest.router)
59
+ client = TestClient(app)
60
+ kwargs = {"json": {}}
61
+ if kind == "redirect":
62
+ kwargs["follow_redirects"] = False
63
+ r = client.post("/widget/download", **kwargs)
64
+ if kind == "auto":
65
+ assert r.json() == {"data": {"pong": True}, "ok": True}
66
+ elif kind == "json":
67
+ assert r.json() == {"pong": True}
68
+ elif kind == "html":
69
+ assert r.text == "<h1>pong</h1>"
70
+ elif kind == "text":
71
+ assert r.text == "pong"
72
+ elif kind == "file":
73
+ assert r.content == file_path.read_bytes()
74
+ elif kind == "stream":
75
+ assert r.content == b"pong"
76
+ elif kind == "redirect":
77
+ assert r.status_code == 307
78
+ assert r.headers["location"] == "/redirected"
79
+ return
80
+ assert r.status_code == 200
81
+
82
+
83
+ def test_response_rest_alias_table_jinja(tmp_path):
84
+ pytest.importorskip("jinja2")
85
+ Widget = build_model_for_jinja_response(tmp_path)
86
+ app = App()
87
+ app.include_router(Widget.rest.router)
88
+ client = TestClient(app)
89
+ r = client.post("/widget/download", json={})
90
+ assert r.status_code == 200
91
+ assert r.text == "<h1>World</h1>"
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+ from types import SimpleNamespace
3
+ import json
4
+ import pytest
5
+
6
+ from tigrbl.bindings import rpc_call
7
+
8
+ from .response_utils import (
9
+ RESPONSE_KINDS,
10
+ build_model_for_response,
11
+ build_model_for_response_non_alias,
12
+ build_ping_model,
13
+ build_model_for_jinja_response,
14
+ )
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_response_rpc_call():
19
+ Widget = build_ping_model()
20
+ api = SimpleNamespace(models={"Widget": Widget})
21
+ result = await rpc_call(api, Widget, "ping", {}, db=SimpleNamespace())
22
+ assert result == {"pong": True}
23
+
24
+
25
+ @pytest.mark.asyncio
26
+ @pytest.mark.parametrize("kind", RESPONSE_KINDS)
27
+ async def test_response_rpc_alias_table(kind, tmp_path):
28
+ Widget, file_path = build_model_for_response(kind, tmp_path)
29
+ api = SimpleNamespace(models={"Widget": Widget})
30
+ result = await rpc_call(api, Widget, "download", {}, db=SimpleNamespace())
31
+ if kind == "auto":
32
+ assert json.loads(result["body"]) == {"data": {"pong": True}, "ok": True}
33
+ elif kind == "json":
34
+ assert json.loads(result["body"]) == {"pong": True}
35
+ elif kind == "html":
36
+ assert result["body"] == b"<h1>pong</h1>"
37
+ elif kind == "text":
38
+ assert result["body"] == b"pong"
39
+ elif kind == "file":
40
+ assert result["path"] == str(file_path)
41
+ elif kind == "stream":
42
+ content = b"".join([chunk async for chunk in result["body_iterator"]])
43
+ assert content == b"pong"
44
+ elif kind == "redirect":
45
+ assert result["status_code"] == 307
46
+ headers = dict(result["raw_headers"])
47
+ assert headers[b"location"].decode() == "/redirected"
48
+ return
49
+ if kind not in {"auto", "json"}:
50
+ assert result["status_code"] == 200
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ @pytest.mark.parametrize("kind", RESPONSE_KINDS)
55
+ async def test_response_rpc_non_alias_table(kind, tmp_path):
56
+ Widget, file_path = build_model_for_response_non_alias(kind, tmp_path)
57
+ api = SimpleNamespace(models={"Widget": Widget})
58
+ result = await rpc_call(api, Widget, "download", {}, db=SimpleNamespace())
59
+ if kind == "auto":
60
+ assert json.loads(result["body"]) == {"data": {"pong": True}, "ok": True}
61
+ elif kind == "json":
62
+ assert json.loads(result["body"]) == {"pong": True}
63
+ elif kind == "html":
64
+ assert result["body"] == b"<h1>pong</h1>"
65
+ elif kind == "text":
66
+ assert result["body"] == b"pong"
67
+ elif kind == "file":
68
+ assert result["path"] == str(file_path)
69
+ elif kind == "stream":
70
+ content = b"".join([chunk async for chunk in result["body_iterator"]])
71
+ assert content == b"pong"
72
+ elif kind == "redirect":
73
+ assert result["status_code"] == 307
74
+ headers = dict(result["raw_headers"])
75
+ assert headers[b"location"].decode() == "/redirected"
76
+ return
77
+ if kind not in {"auto", "json"}:
78
+ assert result["status_code"] == 200
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_response_rpc_alias_table_jinja(tmp_path):
83
+ pytest.importorskip("jinja2")
84
+ Widget = build_model_for_jinja_response(tmp_path)
85
+ api = SimpleNamespace(models={"Widget": Widget})
86
+ result = await rpc_call(api, Widget, "download", {}, db=SimpleNamespace())
87
+ assert result["status_code"] == 200
88
+ assert result["body"] == b"<h1>World</h1>"
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+ import pytest
3
+
4
+ from tigrbl.response import render_template
5
+
6
+ pytest.importorskip("jinja2")
7
+
8
+
9
+ @pytest.mark.asyncio
10
+ async def test_render_template_j2_html(tmp_path):
11
+ tpl = tmp_path / "hello.j2.html"
12
+ tpl.write_text("<h1>{{ name }}</h1>")
13
+ html = await render_template(
14
+ name="hello.j2.html",
15
+ context={"name": "World"},
16
+ search_paths=[str(tmp_path)],
17
+ )
18
+ assert "<h1>World</h1>" in html
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_render_template_html(tmp_path):
23
+ tpl = tmp_path / "plain.html"
24
+ tpl.write_text("<h1>{{ name }}</h1>")
25
+ html = await render_template(
26
+ name="plain.html",
27
+ context={"name": "World"},
28
+ search_paths=[str(tmp_path)],
29
+ )
30
+ assert "<h1>World</h1>" in html
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import importlib
5
+ import json
6
+ import sys
7
+ import types
8
+ from uuid import uuid4
9
+
10
+
11
+ from tigrbl.response.shortcuts import as_json
12
+
13
+
14
+ def test_as_json_serializes_uuid() -> None:
15
+ uid = uuid4()
16
+ resp = as_json({"id": uid}, envelope=False)
17
+ assert resp.media_type == "application/json"
18
+ assert json.loads(resp.body) == {"id": str(uid)}
19
+
20
+
21
+ def test_as_json_serializes_bytes() -> None:
22
+ payload = b"\x00\x01bytes"
23
+ resp = as_json({"blob": payload}, envelope=False)
24
+ assert resp.media_type == "application/json"
25
+ body = json.loads(resp.body)
26
+ assert body == {"blob": base64.b64encode(payload).decode("ascii")}
27
+
28
+
29
+ def test_as_json_fallbacks_when_orjson_unsupported(monkeypatch) -> None:
30
+ import tigrbl.response.shortcuts as shortcuts
31
+
32
+ original_orjson = sys.modules.get("orjson")
33
+ fake_orjson = types.SimpleNamespace(
34
+ OPT_NON_STR_KEYS=0,
35
+ OPT_SERIALIZE_NUMPY=0,
36
+ OPT_SERIALIZE_BYTES=0,
37
+ dumps=lambda *args, **kwargs: (_ for _ in ()).throw(
38
+ TypeError("unsupported option")
39
+ ),
40
+ )
41
+
42
+ monkeypatch.setitem(sys.modules, "orjson", fake_orjson)
43
+ reloaded = importlib.reload(shortcuts)
44
+
45
+ try:
46
+ payload = {"blob": b"\x00\x98bytes"}
47
+ resp = reloaded.as_json(payload, envelope=False)
48
+
49
+ assert resp.media_type == "application/json"
50
+ assert json.loads(resp.body) == {
51
+ "blob": base64.b64encode(payload["blob"]).decode("ascii")
52
+ }
53
+ finally:
54
+ if original_orjson is not None:
55
+ monkeypatch.setitem(sys.modules, "orjson", original_orjson)
56
+ else:
57
+ monkeypatch.delitem(sys.modules, "orjson", raising=False)
58
+ importlib.reload(shortcuts)
@@ -0,0 +1,58 @@
1
+ import pytest
2
+ from tigrbl.bindings.rest import build_router_and_attach
3
+ from tigrbl.orm.mixins import GUIDPk
4
+ from tigrbl.op import OpSpec
5
+ from tigrbl.orm.tables import Base
6
+ from tigrbl.types import Column, String
7
+
8
+
9
+ def _route_map(router) -> dict[str, tuple[str, set[str]]]:
10
+ out: dict[str, tuple[str, set[str]]] = {}
11
+ for r in getattr(router, "routes", []):
12
+ if hasattr(r, "name"):
13
+ name = getattr(r, "name")
14
+ path = getattr(r, "path")
15
+ methods = set(getattr(r, "methods", []) or [])
16
+ else: # pragma: no cover - fallback when FastAPI is missing
17
+ path, methods, _, opts = r
18
+ name = opts.get("name")
19
+ methods = set(methods)
20
+ if name and "." in name:
21
+ alias = name.split(".", 1)[1]
22
+ out[alias] = (path, methods)
23
+ return out
24
+
25
+
26
+ @pytest.mark.parametrize(
27
+ "alias,target,path,methods",
28
+ [
29
+ ("create", "create", "/item", {"POST"}),
30
+ ("read", "read", "/item/{item_id}", {"GET"}),
31
+ ("update", "update", "/item/{item_id}", {"PATCH"}),
32
+ ("replace", "replace", "/item/{item_id}", {"PUT"}),
33
+ ("delete", "delete", "/item/{item_id}", {"DELETE"}),
34
+ ("list", "list", "/item", {"GET"}),
35
+ ("clear", "clear", "/item", {"DELETE"}),
36
+ ("merge", "merge", "/item/{item_id}", {"PATCH"}),
37
+ ("bulk_create", "bulk_create", "/item", {"POST"}),
38
+ ("bulk_update", "bulk_update", "/item", {"PATCH"}),
39
+ ("bulk_replace", "bulk_replace", "/item", {"PUT"}),
40
+ ("bulk_merge", "bulk_merge", "/item", {"PATCH"}),
41
+ ("bulk_delete", "bulk_delete", "/item", {"DELETE"}),
42
+ ("custom_op", "custom", "/item/custom_op", {"POST"}),
43
+ ],
44
+ )
45
+ def test_rest_default_op_verbs(alias, target, path, methods):
46
+ Base.metadata.clear()
47
+
48
+ class Item(Base, GUIDPk):
49
+ __tablename__ = "items"
50
+ name = Column(String, nullable=False)
51
+
52
+ build_router_and_attach(Item, [OpSpec(alias=alias, target=target)])
53
+
54
+ routes = _route_map(Item.rest.router)
55
+ assert alias in routes
56
+ got_path, got_methods = routes[alias]
57
+ assert got_path.lower() == path.lower()
58
+ assert got_methods == methods
@@ -0,0 +1,25 @@
1
+ from tigrbl.bindings.rest import build_router_and_attach
2
+ from tigrbl.orm.mixins import GUIDPk
3
+ from tigrbl.op import OpSpec
4
+ from tigrbl.orm.tables import Base
5
+ from tigrbl.types import Column, String
6
+
7
+
8
+ def test_bulk_delete_suppresses_clear_route():
9
+ Base.metadata.clear()
10
+
11
+ class Item(Base, GUIDPk):
12
+ __tablename__ = "items_bulk_delete_route"
13
+ name = Column(String, nullable=False)
14
+
15
+ build_router_and_attach(
16
+ Item,
17
+ [
18
+ OpSpec(alias="clear", target="clear"),
19
+ OpSpec(alias="bulk_delete", target="bulk_delete"),
20
+ ],
21
+ )
22
+
23
+ aliases = {route.name.split(".", 1)[1] for route in Item.rest.router.routes}
24
+ assert "bulk_delete" in aliases
25
+ assert "clear" not in aliases
@@ -0,0 +1,68 @@
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+ from sqlalchemy import Integer, String
4
+ from sqlalchemy.orm import Mapped
5
+
6
+ from tigrbl import TigrblApp as Tigrblv3
7
+ from tigrbl.engine.shortcuts import mem
8
+ from tigrbl.specs import F, IO, S, acol
9
+ from tigrbl.orm.tables import Base as Base3
10
+
11
+
12
+ @pytest.fixture()
13
+ def client_and_model():
14
+ Base3.metadata.clear()
15
+
16
+ class Gadget(Base3):
17
+ __tablename__ = "gadgets"
18
+ __allow_unmapped__ = True
19
+
20
+ id: Mapped[int] = acol(
21
+ storage=S(type_=Integer, primary_key=True, autoincrement=True)
22
+ )
23
+ name: Mapped[str] = acol(
24
+ storage=S(type_=String, nullable=False),
25
+ field=F(required_in=("create",)),
26
+ io=IO(
27
+ in_verbs=("create", "update", "replace"),
28
+ out_verbs=("read", "list"),
29
+ ),
30
+ )
31
+ age: Mapped[int] = acol(
32
+ storage=S(type_=Integer, nullable=False, default=0),
33
+ io=IO(
34
+ in_verbs=("create", "update", "replace"),
35
+ out_verbs=("read", "list"),
36
+ ),
37
+ )
38
+
39
+ __tigrbl_cols__ = {"id": id, "name": name, "age": age}
40
+
41
+ api = Tigrblv3(engine=mem(async_=False))
42
+ api.include_model(Gadget, prefix="")
43
+ api.initialize()
44
+
45
+ # Remove generated out schemas to exercise jsonable fallback
46
+ Gadget.schemas.read.out = None # type: ignore[attr-defined]
47
+ Gadget.schemas.list.out = None # type: ignore[attr-defined]
48
+
49
+ client = TestClient(api)
50
+ try:
51
+ yield client, Gadget
52
+ finally:
53
+ client.close()
54
+
55
+
56
+ def test_rest_read_and_list_without_schema(client_and_model):
57
+ client, _ = client_and_model
58
+ created = client.post("/gadget", json={"name": "A", "age": 1})
59
+ item_id = created.json()["id"]
60
+
61
+ resp = client.get(f"/gadget/{item_id}")
62
+ assert resp.status_code == 200
63
+ assert resp.json()["id"] == item_id
64
+
65
+ resp_list = client.get("/gadget")
66
+ assert resp_list.status_code == 200
67
+ ids = {item["id"] for item in resp_list.json()}
68
+ assert item_id in ids
@@ -0,0 +1,36 @@
1
+ from fastapi import FastAPI
2
+
3
+ from tigrbl.bindings.rest.router import _build_router
4
+ from tigrbl.op import OpSpec
5
+ from tigrbl.orm.mixins import GUIDPk
6
+ from tigrbl.orm.tables import Base
7
+
8
+
9
+ class Item(Base, GUIDPk):
10
+ __tablename__ = "items_operation_id"
11
+
12
+
13
+ def _collect_operation_ids(schema: dict) -> list[str]:
14
+ ids: list[str] = []
15
+ for path in schema["paths"].values():
16
+ for method in path.values():
17
+ operation_id = method.get("operationId")
18
+ if operation_id:
19
+ ids.append(operation_id)
20
+ return ids
21
+
22
+
23
+ def test_operation_ids_are_unique():
24
+ Base.metadata.clear()
25
+ router = _build_router(
26
+ Item,
27
+ [
28
+ OpSpec(alias="dup", target="custom", arity="collection"),
29
+ OpSpec(alias="dup", target="custom", arity="member"),
30
+ ],
31
+ )
32
+ app = FastAPI()
33
+ app.include_router(router)
34
+ schema = app.openapi()
35
+ operation_ids = _collect_operation_ids(schema)
36
+ assert len(operation_ids) == len(set(operation_ids))
@@ -0,0 +1,86 @@
1
+ import pytest
2
+
3
+
4
+ from tigrbl import TigrblApp
5
+ from tigrbl.orm.mixins import GUIDPk, BulkCapable, Mergeable
6
+ from tigrbl.orm.tables import Base
7
+ from tigrbl.op.types import CANON
8
+ from tigrbl.types import Column, String
9
+
10
+
11
+ def _route_map(router) -> dict[str, tuple[str, set[str]]]:
12
+ out: dict[str, tuple[str, set[str]]] = {}
13
+ for r in getattr(router, "routes", []):
14
+ if hasattr(r, "name"):
15
+ name = getattr(r, "name")
16
+ path = getattr(r, "path")
17
+ methods = set(getattr(r, "methods", []) or [])
18
+ else: # pragma: no cover - fallback when FastAPI is missing
19
+ path, methods, _, opts = r
20
+ name = opts.get("name")
21
+ methods = set(methods)
22
+ if name and "." in name:
23
+ alias = name.split(".", 1)[1]
24
+ out[alias] = (path, methods)
25
+ return out
26
+
27
+
28
+ @pytest.mark.parametrize(
29
+ "alias,target,path,methods",
30
+ [
31
+ ("create", "create", "/item", {"POST"}),
32
+ ("read", "read", "/item/{item_id}", {"GET"}),
33
+ ("update", "update", "/item/{item_id}", {"PATCH"}),
34
+ ("replace", "replace", "/item/{item_id}", {"PUT"}),
35
+ ("delete", "delete", "/item/{item_id}", {"DELETE"}),
36
+ ("list", "list", "/item", {"GET"}),
37
+ ("clear", "clear", "/item", {"DELETE"}),
38
+ ("merge", "merge", "/item/{item_id}", {"PATCH"}),
39
+ ("bulk_create", "bulk_create", "/item", {"POST"}),
40
+ ("bulk_update", "bulk_update", "/item", {"PATCH"}),
41
+ ("bulk_replace", "bulk_replace", "/item", {"PUT"}),
42
+ ("bulk_merge", "bulk_merge", "/item", {"PATCH"}),
43
+ ("bulk_delete", "bulk_delete", "/item", {"DELETE"}),
44
+ ],
45
+ )
46
+ def test_rest_rpc_parity_for_default_verbs(alias, target, path, methods):
47
+ Base.metadata.clear()
48
+
49
+ class Item(Base, GUIDPk, BulkCapable, Mergeable):
50
+ __tablename__ = "items"
51
+ name = Column(String, nullable=False)
52
+
53
+ Item.__tigrbl_ops__ = {verb: {"target": verb} for verb in CANON if verb != "custom"}
54
+
55
+ api = TigrblApp()
56
+ api.include_model(Item, mount_router=False)
57
+
58
+ routes = _route_map(Item.rest.router)
59
+ if alias == "clear" and "bulk_delete" in routes:
60
+ assert alias not in routes
61
+ elif alias == "create" and "bulk_create" in routes:
62
+ assert alias not in routes
63
+ else:
64
+ assert alias in routes
65
+ got_path, got_methods = routes[alias]
66
+ assert got_path.lower() == path.lower()
67
+ assert got_methods == methods
68
+
69
+ assert hasattr(api.rpc.Item, alias)
70
+
71
+
72
+ def test_non_bulkcapable_prefers_create() -> None:
73
+ Base.metadata.clear()
74
+
75
+ class Item(Base, GUIDPk, Mergeable):
76
+ __tablename__ = "items"
77
+ name = Column(String, nullable=False)
78
+
79
+ api = TigrblApp()
80
+ api.include_model(Item, mount_router=False)
81
+
82
+ routes = _route_map(Item.rest.router)
83
+ assert "bulk_create" not in routes
84
+ assert "create" in routes
85
+ assert hasattr(api.rpc.Item, "create")
86
+ assert not hasattr(api.rpc.Item, "bulk_create")
@@ -0,0 +1,40 @@
1
+ from tigrbl import TigrblApp
2
+ from tigrbl.orm.tables import Base
3
+ from tigrbl.orm.mixins import GUIDPk
4
+ from tigrbl.types import Column, String
5
+
6
+
7
+ def _router_paths(api, name: str) -> set[str]:
8
+ router = api.routers.get(name)
9
+ return {r.path for r in getattr(router, "routes", [])}
10
+
11
+
12
+ def test_default_resource_and_rpc_prefixes():
13
+ Base.metadata.clear()
14
+
15
+ class Item(Base, GUIDPk):
16
+ __tablename__ = "items"
17
+ name = Column(String, nullable=False)
18
+
19
+ api = TigrblApp()
20
+ api.include_model(Item, mount_router=False)
21
+
22
+ paths = {p.lower() for p in _router_paths(api, "Item")}
23
+ assert "/item" in paths
24
+ assert hasattr(api.rpc, "Item")
25
+
26
+
27
+ def test_resource_override_affects_prefixes():
28
+ Base.metadata.clear()
29
+
30
+ class Item(Base, GUIDPk):
31
+ __tablename__ = "items"
32
+ __resource__ = "test"
33
+ name = Column(String, nullable=False)
34
+
35
+ api = TigrblApp()
36
+ api.include_model(Item, mount_router=False)
37
+
38
+ paths = {p.lower() for p in _router_paths(api, "Item")}
39
+ assert "/test" in paths
40
+ assert hasattr(api.rpc, "Test")