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.
- tests/__init__.py +0 -0
- tests/conftest.py +285 -0
- tests/i9n/__init__.py +0 -0
- tests/i9n/test_acronym_route_name.py +16 -0
- tests/i9n/test_allow_anon.py +239 -0
- tests/i9n/test_apikey_generation.py +47 -0
- tests/i9n/test_authn_provider_integration.py +67 -0
- tests/i9n/test_bindings_integration.py +108 -0
- tests/i9n/test_bindings_modules.py +149 -0
- tests/i9n/test_bulk_docs_client.py +99 -0
- tests/i9n/test_core_access.py +164 -0
- tests/i9n/test_error_mappings.py +360 -0
- tests/i9n/test_field_spec_effects.py +117 -0
- tests/i9n/test_header_io_uvicorn.py +71 -0
- tests/i9n/test_healthz_methodz_hookz.py +203 -0
- tests/i9n/test_hook_ctx_v3_i9n.py +392 -0
- tests/i9n/test_hook_lifecycle.py +452 -0
- tests/i9n/test_iospec_attributes.py +368 -0
- tests/i9n/test_iospec_integration.py +181 -0
- tests/i9n/test_key_digest_uvicorn.py +151 -0
- tests/i9n/test_list_filters_optional.py +20 -0
- tests/i9n/test_mixins.py +534 -0
- tests/i9n/test_nested_path_schema_and_rpc.py +34 -0
- tests/i9n/test_nested_routing_depth.py +118 -0
- tests/i9n/test_op_ctx_alias_examples.py +62 -0
- tests/i9n/test_op_ctx_behavior.py +395 -0
- tests/i9n/test_op_ctx_core_crud_order.py +219 -0
- tests/i9n/test_openapi_clear_response_schema.py +35 -0
- tests/i9n/test_openapi_schema_examples_presence.py +81 -0
- tests/i9n/test_opspec_effects_i9n_test.py +193 -0
- tests/i9n/test_owner_tenant_policy.py +173 -0
- tests/i9n/test_request_extras.py +74 -0
- tests/i9n/test_request_extras_provider.py +27 -0
- tests/i9n/test_response_extras_provider.py +22 -0
- tests/i9n/test_rest_fallback_serialization.py +66 -0
- tests/i9n/test_rest_row_serialization.py +81 -0
- tests/i9n/test_rest_rpc_parity_v3.py +0 -0
- tests/i9n/test_row_result_serialization.py +84 -0
- tests/i9n/test_schema.py +45 -0
- tests/i9n/test_schema_ctx_attributes_integration.py +178 -0
- tests/i9n/test_schema_ctx_op_ctx_integration.py +88 -0
- tests/i9n/test_schema_ctx_spec_integration.py +209 -0
- tests/i9n/test_sqlite_attachments.py +36 -0
- tests/i9n/test_storage_spec_integration.py +126 -0
- tests/i9n/test_symmetry_parity.py +26 -0
- tests/i9n/test_v3_bulk_rest_endpoints.py +120 -0
- tests/i9n/test_v3_default_rest_ops.py +145 -0
- tests/i9n/test_v3_default_rpc_ops.py +234 -0
- tests/i9n/test_v3_opspec_attributes.py +272 -0
- tests/i9n/test_verb_alias_policy.py +57 -0
- tests/i9n/uvicorn_utils.py +43 -0
- tests/perf/__init__.py +0 -0
- tests/perf/test_collect_caching.py +42 -0
- tests/perf/test_hookz_performance.py +89 -0
- tests/perf/test_methodz_performance.py +99 -0
- tests/unit/__init__.py +0 -0
- tests/unit/decorators/test_alias_ctx_bindings.py +34 -0
- tests/unit/decorators/test_engine_ctx_bindings.py +57 -0
- tests/unit/decorators/test_hook_ctx_bindings.py +53 -0
- tests/unit/decorators/test_op_alias_bindings.py +39 -0
- tests/unit/decorators/test_op_ctx_bindings.py +82 -0
- tests/unit/decorators/test_response_ctx_bindings.py +32 -0
- tests/unit/decorators/test_schema_ctx_bindings.py +39 -0
- tests/unit/response_utils.py +142 -0
- tests/unit/runtime/atoms/test_emit_paired_post.py +27 -0
- tests/unit/runtime/atoms/test_emit_paired_pre.py +38 -0
- tests/unit/runtime/atoms/test_emit_readtime_alias.py +41 -0
- tests/unit/runtime/atoms/test_out_masking.py +76 -0
- tests/unit/runtime/atoms/test_refresh_demand.py +43 -0
- tests/unit/runtime/atoms/test_resolve_assemble.py +45 -0
- tests/unit/runtime/atoms/test_resolve_paired_gen.py +65 -0
- tests/unit/runtime/atoms/test_schema_collect_in.py +44 -0
- tests/unit/runtime/atoms/test_schema_collect_out.py +43 -0
- tests/unit/runtime/atoms/test_storage_to_stored.py +45 -0
- tests/unit/runtime/atoms/test_wire_build_in.py +13 -0
- tests/unit/runtime/atoms/test_wire_build_out.py +40 -0
- tests/unit/runtime/atoms/test_wire_dump.py +14 -0
- tests/unit/runtime/atoms/test_wire_validate_in.py +69 -0
- tests/unit/runtime/test_events_phases.py +14 -0
- tests/unit/test_acol_vcol_knobs.py +96 -0
- tests/unit/test_alias_ctx_op_alias_attributes.py +75 -0
- tests/unit/test_alias_ctx_op_attributes.py +61 -0
- tests/unit/test_api_level_set_auth.py +25 -0
- tests/unit/test_app_model_defaults.py +28 -0
- tests/unit/test_app_reexport.py +6 -0
- tests/unit/test_base_facade_initialize.py +71 -0
- tests/unit/test_build_list_params_spec_model.py +33 -0
- tests/unit/test_bulk_body_annotation.py +23 -0
- tests/unit/test_bulk_response_schema.py +153 -0
- tests/unit/test_colspec_map_isolation.py +19 -0
- tests/unit/test_column_collect_mixins.py +21 -0
- tests/unit/test_column_rest_rpc_results.py +298 -0
- tests/unit/test_column_table_orm_binding.py +51 -0
- tests/unit/test_config_dataclass_none.py +12 -0
- tests/unit/test_core_crud_bulk_ops.py +160 -0
- tests/unit/test_core_crud_default_ops.py +174 -0
- tests/unit/test_core_crud_methods.py +337 -0
- tests/unit/test_core_wrap_memoization.py +67 -0
- tests/unit/test_db_dependency.py +19 -0
- tests/unit/test_decorator_and_collect.py +47 -0
- tests/unit/test_default_tags.py +20 -0
- tests/unit/test_engine_spec_and_shortcuts.py +84 -0
- tests/unit/test_engine_usage_levels.py +36 -0
- tests/unit/test_field_spec_attrs.py +95 -0
- tests/unit/test_file_response.py +148 -0
- tests/unit/test_handler_step_qualname.py +30 -0
- tests/unit/test_hook_ctx_attributes.py +33 -0
- tests/unit/test_hook_ctx_binding.py +55 -0
- tests/unit/test_hookz_empty_phase.py +37 -0
- tests/unit/test_hybrid_session_run_sync.py +18 -0
- tests/unit/test_in_tx.py +16 -0
- tests/unit/test_include_models_base_prefix.py +29 -0
- tests/unit/test_initialize_cross_ddl.py +26 -0
- tests/unit/test_io_spec_attributes.py +171 -0
- tests/unit/test_iospec_attributes.py +90 -0
- tests/unit/test_iospec_effects.py +173 -0
- tests/unit/test_jsonrpc_id_example.py +9 -0
- tests/unit/test_jsonrpc_router_default_tag.py +10 -0
- tests/unit/test_kernel_invoke_ctx.py +14 -0
- tests/unit/test_kernel_opview_on_demand.py +42 -0
- tests/unit/test_kernel_plan_labels.py +40 -0
- tests/unit/test_kernelz_endpoint.py +65 -0
- tests/unit/test_make_column_shortcuts.py +80 -0
- tests/unit/test_mixins_sqlalchemy.py +13 -0
- tests/unit/test_op_alias.py +70 -0
- tests/unit/test_op_class_engine_binding.py +38 -0
- tests/unit/test_op_ctx_arity_paths.py +83 -0
- tests/unit/test_op_ctx_attributes.py +147 -0
- tests/unit/test_op_ctx_core_crud_integration.py +376 -0
- tests/unit/test_op_ctx_dynamic_attach.py +19 -0
- tests/unit/test_op_ctx_persist_options.py +75 -0
- tests/unit/test_opspec_effects.py +153 -0
- tests/unit/test_postgres_engine_errors.py +17 -0
- tests/unit/test_postgres_env_vars.py +17 -0
- tests/unit/test_relationship_alias_cols.py +98 -0
- tests/unit/test_request_body_schema.py +54 -0
- tests/unit/test_request_response_examples.py +169 -0
- tests/unit/test_resolver_precedence.py +49 -0
- tests/unit/test_response_alias_table_rpc.py +40 -0
- tests/unit/test_response_ctx_precedence.py +62 -0
- tests/unit/test_response_diagnostics_kernelz.py +81 -0
- tests/unit/test_response_html_jinja_behavior.py +116 -0
- tests/unit/test_response_parity.py +20 -0
- tests/unit/test_response_rest.py +91 -0
- tests/unit/test_response_rpc.py +88 -0
- tests/unit/test_response_template.py +30 -0
- tests/unit/test_response_uuid.py +58 -0
- tests/unit/test_rest_all_default_op_verbs.py +58 -0
- tests/unit/test_rest_bulk_delete_suppresses_clear.py +25 -0
- tests/unit/test_rest_no_schema_jsonable.py +68 -0
- tests/unit/test_rest_operation_id_uniqueness.py +36 -0
- tests/unit/test_rest_rpc_parity_default_ops.py +86 -0
- tests/unit/test_rest_rpc_prefixes.py +40 -0
- tests/unit/test_rest_rpc_symmetry.py +151 -0
- tests/unit/test_rpc_all_default_op_verbs.py +224 -0
- tests/unit/test_rpc_default_ops.py +111 -0
- tests/unit/test_schema_ctx_attributes.py +96 -0
- tests/unit/test_schema_ctx_plain_class.py +34 -0
- tests/unit/test_schema_spec_presence.py +36 -0
- tests/unit/test_schemas_binding.py +29 -0
- tests/unit/test_security_per_route.py +43 -0
- tests/unit/test_should_wire_canonical.py +62 -0
- tests/unit/test_spec_api.py +50 -0
- tests/unit/test_spec_app.py +28 -0
- tests/unit/test_spec_column.py +24 -0
- tests/unit/test_spec_engine.py +61 -0
- tests/unit/test_spec_field.py +16 -0
- tests/unit/test_spec_hook.py +35 -0
- tests/unit/test_spec_io.py +29 -0
- tests/unit/test_spec_op.py +31 -0
- tests/unit/test_spec_storage.py +21 -0
- tests/unit/test_spec_table.py +21 -0
- tests/unit/test_sqlite_attachments.py +57 -0
- tests/unit/test_storage_spec_attributes.py +78 -0
- tests/unit/test_sys_handler_crud.py +86 -0
- tests/unit/test_sys_run_rollback.py +42 -0
- tests/unit/test_sys_tx_async_begin.py +45 -0
- tests/unit/test_sys_tx_begin.py +49 -0
- tests/unit/test_sys_tx_commit.py +59 -0
- tests/unit/test_table_base_exports.py +22 -0
- tests/unit/test_table_collect_spec.py +41 -0
- tests/unit/test_table_columns_namespace.py +21 -0
- tests/unit/test_v3_favicon_endpoint.py +17 -0
- tests/unit/test_v3_healthz_endpoint.py +39 -0
- tests/unit/test_v3_op_alias.py +88 -0
- tests/unit/test_v3_op_ctx_attributes.py +99 -0
- tests/unit/test_v3_schemas_and_decorators.py +114 -0
- tests/unit/test_v3_storage_spec_attributes.py +249 -0
- tigrbl_tests-0.3.0.dist-info/METADATA +103 -0
- tigrbl_tests-0.3.0.dist-info/RECORD +192 -0
- tigrbl_tests-0.3.0.dist-info/WHEEL +4 -0
- 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")
|