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,81 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from tigrbl import TigrblApp, Base
|
|
3
|
+
from tigrbl.engine.shortcuts import mem
|
|
4
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
5
|
+
from tigrbl.specs import F, S, acol
|
|
6
|
+
from tigrbl.types import App, Mapped, String
|
|
7
|
+
from httpx import ASGITransport, AsyncClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Widget(Base, GUIDPk):
|
|
11
|
+
__tablename__ = "widgets_example_presence"
|
|
12
|
+
name: Mapped[str] = acol(
|
|
13
|
+
storage=S(String, nullable=False), field=F(constraints={"examples": ["foo"]})
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_schema(spec, schema):
|
|
18
|
+
if "$ref" in schema:
|
|
19
|
+
ref = schema["$ref"].split("/")[-1]
|
|
20
|
+
return spec["components"]["schemas"][ref]
|
|
21
|
+
if "anyOf" in schema:
|
|
22
|
+
return _resolve_schema(spec, schema["anyOf"][0])
|
|
23
|
+
if "items" in schema:
|
|
24
|
+
item = _resolve_schema(spec, schema["items"])
|
|
25
|
+
schema["items"] = item
|
|
26
|
+
if "examples" not in schema and "examples" in item:
|
|
27
|
+
schema["examples"] = [item["examples"][0]]
|
|
28
|
+
return schema
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
@pytest.mark.i9n
|
|
33
|
+
async def test_openapi_examples_and_schemas_present(db_mode):
|
|
34
|
+
fastapi_app = App()
|
|
35
|
+
engine = mem() if db_mode == "async" else mem(async_=False)
|
|
36
|
+
api = TigrblApp(engine=engine)
|
|
37
|
+
api.include_model(Widget)
|
|
38
|
+
if db_mode == "async":
|
|
39
|
+
await api.initialize()
|
|
40
|
+
else:
|
|
41
|
+
api.initialize()
|
|
42
|
+
api.mount_jsonrpc()
|
|
43
|
+
fastapi_app.include_router(api.router)
|
|
44
|
+
|
|
45
|
+
transport = ASGITransport(app=fastapi_app)
|
|
46
|
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
47
|
+
spec = (await client.get("/openapi.json")).json()
|
|
48
|
+
|
|
49
|
+
path = f"/{Widget.__name__.lower()}"
|
|
50
|
+
create_req = spec["paths"][path]["post"]["requestBody"]["content"][
|
|
51
|
+
"application/json"
|
|
52
|
+
]["schema"]
|
|
53
|
+
create_resp = spec["paths"][path]["post"]["responses"]["201"]["content"][
|
|
54
|
+
"application/json"
|
|
55
|
+
]["schema"]
|
|
56
|
+
create_req = _resolve_schema(spec, create_req)
|
|
57
|
+
create_resp = _resolve_schema(spec, create_resp)
|
|
58
|
+
assert create_req["properties"]["name"]["examples"][0] == "foo"
|
|
59
|
+
assert create_resp["properties"]["name"]["examples"][0] == "foo"
|
|
60
|
+
|
|
61
|
+
expected = {
|
|
62
|
+
"WidgetClearResponse",
|
|
63
|
+
"WidgetCreateRequest",
|
|
64
|
+
"WidgetCreateResponse",
|
|
65
|
+
"WidgetDeleteResponse",
|
|
66
|
+
"WidgetListResponse",
|
|
67
|
+
"WidgetReadResponse",
|
|
68
|
+
"WidgetReplaceRequest",
|
|
69
|
+
"WidgetReplaceResponse",
|
|
70
|
+
"WidgetUpdateRequest",
|
|
71
|
+
"WidgetUpdateResponse",
|
|
72
|
+
}
|
|
73
|
+
assert expected <= set(spec["components"]["schemas"])
|
|
74
|
+
|
|
75
|
+
assert hasattr(api.schemas, "Widget")
|
|
76
|
+
widget_ns = getattr(api.schemas, "Widget")
|
|
77
|
+
for alias in ["create", "read", "update", "replace", "delete", "list", "clear"]:
|
|
78
|
+
assert hasattr(widget_ns, alias)
|
|
79
|
+
op_ns = getattr(widget_ns, alias)
|
|
80
|
+
assert hasattr(op_ns, "in_")
|
|
81
|
+
assert hasattr(op_ns, "out")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from tigrbl.types import App
|
|
5
|
+
from sqlalchemy import String, create_engine
|
|
6
|
+
from sqlalchemy.orm import sessionmaker
|
|
7
|
+
|
|
8
|
+
from tigrbl import core as _core
|
|
9
|
+
from tigrbl.bindings.model import bind
|
|
10
|
+
from tigrbl.hook import hook_ctx
|
|
11
|
+
from tigrbl.op.types import PHASES
|
|
12
|
+
from tigrbl.runtime import system as runtime_system
|
|
13
|
+
from tigrbl.runtime.kernel import build_phase_chains
|
|
14
|
+
from tigrbl.specs import IO, S, acol
|
|
15
|
+
from tigrbl.orm.tables import Base
|
|
16
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# --- models --------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# NOTE:
|
|
23
|
+
# Historically this test called ``Base.metadata.clear()`` at import time to
|
|
24
|
+
# ensure a pristine declarative registry. When the test module is imported as
|
|
25
|
+
# part of the full suite, clearing the global metadata wipes out tables defined
|
|
26
|
+
# by earlier tests which still rely on the shared ``Base``. Subsequent tests
|
|
27
|
+
# would then fail with missing table/column errors (manifesting as HTTP 503
|
|
28
|
+
# responses) because their models lost their metadata. The table names used in
|
|
29
|
+
# this module are unique, so we can simply avoid clearing the global metadata to
|
|
30
|
+
# preserve isolation without impacting other tests.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Gadget(Base, GUIDPk):
|
|
34
|
+
__tablename__ = "gadgets_opspec"
|
|
35
|
+
__allow_unmapped__ = True
|
|
36
|
+
|
|
37
|
+
name = acol(
|
|
38
|
+
storage=S(type_=String, nullable=False, default="anon"),
|
|
39
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Hooked(Base, GUIDPk):
|
|
44
|
+
__tablename__ = "hooked_opspec"
|
|
45
|
+
__allow_unmapped__ = True
|
|
46
|
+
|
|
47
|
+
name = acol(
|
|
48
|
+
storage=S(type_=String, nullable=False),
|
|
49
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@hook_ctx(ops="create", phase="PRE_HANDLER")
|
|
53
|
+
def inject_name(cls, ctx):
|
|
54
|
+
payload = dict(ctx.get("payload") or {})
|
|
55
|
+
payload.setdefault("name", "hooked")
|
|
56
|
+
ctx["payload"] = payload
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- helpers -------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
GADGET_TABLE = Gadget.__table__
|
|
62
|
+
HOOKED_TABLE = Hooked.__table__
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ensure_tables():
|
|
66
|
+
for model, table in ((Gadget, GADGET_TABLE), (Hooked, HOOKED_TABLE)):
|
|
67
|
+
if not hasattr(model, "__table__"):
|
|
68
|
+
model.__table__ = table # type: ignore[attr-defined]
|
|
69
|
+
if table.key not in Base.metadata.tables:
|
|
70
|
+
Base.metadata._add_table(table.name, table.schema, table)
|
|
71
|
+
if not hasattr(model, "__mapper__"):
|
|
72
|
+
Base.registry.map_imperatively(model, table)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fresh_session():
|
|
76
|
+
engine = create_engine("sqlite:///:memory:")
|
|
77
|
+
_ensure_tables()
|
|
78
|
+
Base.metadata.create_all(bind=engine, tables=[Gadget.__table__, Hooked.__table__])
|
|
79
|
+
return sessionmaker(bind=engine)()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- tests ---------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.i9n
|
|
86
|
+
def test_request_and_response_schemas():
|
|
87
|
+
_ensure_tables()
|
|
88
|
+
bind(Gadget)
|
|
89
|
+
assert hasattr(Gadget.schemas, "create")
|
|
90
|
+
assert hasattr(Gadget.schemas.create, "in_")
|
|
91
|
+
assert hasattr(Gadget.schemas, "read")
|
|
92
|
+
assert hasattr(Gadget.schemas.read, "out")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.i9n
|
|
96
|
+
def test_columns_bound():
|
|
97
|
+
_ensure_tables()
|
|
98
|
+
bind(Gadget)
|
|
99
|
+
assert "name" in Gadget.__table__.c
|
|
100
|
+
assert "name" in Gadget.__tigrbl_cols__
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.i9n
|
|
104
|
+
def test_defaults_value_resolution():
|
|
105
|
+
_ensure_tables()
|
|
106
|
+
bind(Gadget)
|
|
107
|
+
db = _fresh_session()
|
|
108
|
+
obj = asyncio.run(_core.create(Gadget, db=db, data={}))
|
|
109
|
+
assert obj.name == "anon"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.i9n
|
|
113
|
+
def test_internal_model_opspec_binding():
|
|
114
|
+
_ensure_tables()
|
|
115
|
+
bind(Gadget)
|
|
116
|
+
sp = Gadget.opspecs.by_alias["create"][0]
|
|
117
|
+
assert sp.table is Gadget
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.i9n
|
|
121
|
+
def test_openapi_includes_path():
|
|
122
|
+
_ensure_tables()
|
|
123
|
+
bind(Gadget)
|
|
124
|
+
app = App()
|
|
125
|
+
app.include_router(Gadget.rest.router)
|
|
126
|
+
schema = app.openapi()
|
|
127
|
+
assert "/gadget" in schema["paths"]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.i9n
|
|
131
|
+
def test_storage_and_sqlalchemy_persist():
|
|
132
|
+
_ensure_tables()
|
|
133
|
+
bind(Gadget)
|
|
134
|
+
db = _fresh_session()
|
|
135
|
+
asyncio.run(_core.create(Gadget, db=db, data={"name": "stored"}))
|
|
136
|
+
fetched = db.query(Gadget).one()
|
|
137
|
+
assert fetched.name == "stored"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.i9n
|
|
141
|
+
def test_rest_routes_bound():
|
|
142
|
+
_ensure_tables()
|
|
143
|
+
session = _fresh_session()
|
|
144
|
+
|
|
145
|
+
def get_db():
|
|
146
|
+
return session
|
|
147
|
+
|
|
148
|
+
Gadget.__tigrbl_get_db__ = staticmethod(get_db) # type: ignore[attr-defined]
|
|
149
|
+
bind(Gadget)
|
|
150
|
+
app = App()
|
|
151
|
+
app.include_router(Gadget.rest.router)
|
|
152
|
+
paths = {route.path for route in app.router.routes}
|
|
153
|
+
assert "/gadget" in paths
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.i9n
|
|
157
|
+
def test_rpc_method_bound():
|
|
158
|
+
_ensure_tables()
|
|
159
|
+
bind(Gadget)
|
|
160
|
+
assert hasattr(Gadget.rpc, "create")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.i9n
|
|
164
|
+
def test_core_crud_handler_used():
|
|
165
|
+
_ensure_tables()
|
|
166
|
+
bind(Gadget)
|
|
167
|
+
step = Gadget.hooks.create.HANDLER[0]
|
|
168
|
+
assert step.__qualname__ == _core.create.__qualname__
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.i9n
|
|
172
|
+
def test_hook_execution():
|
|
173
|
+
_ensure_tables()
|
|
174
|
+
bind(Hooked)
|
|
175
|
+
assert Hooked.hooks.create.PRE_HANDLER
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.i9n
|
|
179
|
+
def test_atom_injection():
|
|
180
|
+
_ensure_tables()
|
|
181
|
+
bind(Gadget)
|
|
182
|
+
chains = build_phase_chains(Gadget, "create")
|
|
183
|
+
non_handler = [ph for ph in PHASES if ph != "HANDLER" and chains.get(ph)]
|
|
184
|
+
# atom discovery injects steps into additional phases beyond the handler
|
|
185
|
+
assert non_handler
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.i9n
|
|
189
|
+
def test_system_step_registry():
|
|
190
|
+
subjects = runtime_system.subjects()
|
|
191
|
+
assert ("txn", "begin") in subjects
|
|
192
|
+
assert ("handler", "crud") in subjects
|
|
193
|
+
assert ("txn", "commit") in subjects
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from tigrbl.types import App, HTTPException, Request, Security
|
|
2
|
+
from typing import Iterable
|
|
3
|
+
|
|
4
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
import pytest
|
|
7
|
+
import uuid
|
|
8
|
+
from sqlalchemy import Column, String
|
|
9
|
+
|
|
10
|
+
from tigrbl import TigrblApp, Base
|
|
11
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
12
|
+
from tigrbl.orm.mixins.ownable import Ownable, OwnerPolicy
|
|
13
|
+
from tigrbl.orm.mixins.tenant_bound import TenantBound, TenantPolicy
|
|
14
|
+
from tigrbl.config.constants import TIGRBL_AUTH_CONTEXT_ATTR
|
|
15
|
+
from tigrbl.types.authn_abc import AuthNProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DummyAuth(AuthNProvider):
|
|
19
|
+
def __init__(self, user_id: uuid.UUID, tenant_id: uuid.UUID):
|
|
20
|
+
self.user_id = user_id
|
|
21
|
+
self.tenant_id = tenant_id
|
|
22
|
+
|
|
23
|
+
async def get_principal(
|
|
24
|
+
self,
|
|
25
|
+
request: Request,
|
|
26
|
+
creds: HTTPAuthorizationCredentials = Security(HTTPBearer()),
|
|
27
|
+
):
|
|
28
|
+
if creds.credentials != "secret":
|
|
29
|
+
raise HTTPException(status_code=401)
|
|
30
|
+
principal = {"user_id": self.user_id, "tenant_id": self.tenant_id}
|
|
31
|
+
request.state.principal = principal
|
|
32
|
+
setattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, principal)
|
|
33
|
+
return principal
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _client_for_owner(
|
|
37
|
+
policy: OwnerPolicy,
|
|
38
|
+
user_id: uuid.UUID,
|
|
39
|
+
tenant_id: uuid.UUID,
|
|
40
|
+
extra_user_ids: Iterable[uuid.UUID] | None = None,
|
|
41
|
+
) -> TestClient:
|
|
42
|
+
Base.metadata.clear()
|
|
43
|
+
|
|
44
|
+
class User(Base, GUIDPk):
|
|
45
|
+
__tablename__ = "users"
|
|
46
|
+
__table_args__ = {"schema": "main"}
|
|
47
|
+
name = Column(String, nullable=False)
|
|
48
|
+
|
|
49
|
+
class Item(Base, GUIDPk, Ownable):
|
|
50
|
+
__tablename__ = "items"
|
|
51
|
+
__table_args__ = {"schema": "main"}
|
|
52
|
+
name = Column(String, nullable=False)
|
|
53
|
+
__tigrbl_owner_policy__ = policy
|
|
54
|
+
|
|
55
|
+
from tigrbl.engine.shortcuts import mem
|
|
56
|
+
from tigrbl.engine.engine_spec import EngineSpec
|
|
57
|
+
from tigrbl.engine._engine import Engine
|
|
58
|
+
|
|
59
|
+
engine = Engine(EngineSpec.from_any(mem(async_=False)))
|
|
60
|
+
db_engine, _ = engine.raw()
|
|
61
|
+
Base.metadata.create_all(bind=db_engine)
|
|
62
|
+
|
|
63
|
+
with engine.session() as session:
|
|
64
|
+
session.execute(User.__table__.insert().values(id=user_id, name="owner"))
|
|
65
|
+
for extra in extra_user_ids or []:
|
|
66
|
+
session.execute(User.__table__.insert().values(id=extra, name="extra"))
|
|
67
|
+
session.commit()
|
|
68
|
+
|
|
69
|
+
authn = DummyAuth(user_id, tenant_id)
|
|
70
|
+
api = TigrblApp(engine=engine)
|
|
71
|
+
api.set_auth(authn=authn.get_principal)
|
|
72
|
+
api.include_models([User, Item])
|
|
73
|
+
app = App()
|
|
74
|
+
app.include_router(api.router)
|
|
75
|
+
api.initialize()
|
|
76
|
+
return TestClient(app)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.i9n
|
|
80
|
+
def test_owner_policy_runtime_switch():
|
|
81
|
+
user_id = uuid.uuid4()
|
|
82
|
+
tenant_id = uuid.uuid4()
|
|
83
|
+
headers = {"Authorization": "Bearer secret"}
|
|
84
|
+
|
|
85
|
+
client = _client_for_owner(OwnerPolicy.STRICT_SERVER, user_id, tenant_id)
|
|
86
|
+
res = client.post(
|
|
87
|
+
"/item",
|
|
88
|
+
json={"name": "one", "owner_id": str(user_id)},
|
|
89
|
+
headers=headers,
|
|
90
|
+
)
|
|
91
|
+
assert res.status_code == 400
|
|
92
|
+
|
|
93
|
+
supplied = uuid.uuid4()
|
|
94
|
+
client = _client_for_owner(
|
|
95
|
+
OwnerPolicy.CLIENT_SET, user_id, tenant_id, extra_user_ids=[supplied]
|
|
96
|
+
)
|
|
97
|
+
res = client.post(
|
|
98
|
+
"/item",
|
|
99
|
+
json={"name": "two", "owner_id": str(supplied)},
|
|
100
|
+
headers=headers,
|
|
101
|
+
)
|
|
102
|
+
assert res.status_code == 201
|
|
103
|
+
assert res.json()["owner_id"] == str(supplied)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _client_for_tenant(
|
|
107
|
+
policy: TenantPolicy,
|
|
108
|
+
user_id: uuid.UUID,
|
|
109
|
+
tenant_id: uuid.UUID,
|
|
110
|
+
extra_tenant_ids: Iterable[uuid.UUID] | None = None,
|
|
111
|
+
) -> TestClient:
|
|
112
|
+
Base.metadata.clear()
|
|
113
|
+
|
|
114
|
+
class Tenant(Base, GUIDPk):
|
|
115
|
+
__tablename__ = "tenants"
|
|
116
|
+
__table_args__ = {"schema": "main"}
|
|
117
|
+
name = Column(String, nullable=False)
|
|
118
|
+
|
|
119
|
+
class Item(Base, GUIDPk, TenantBound):
|
|
120
|
+
__tablename__ = "items"
|
|
121
|
+
__table_args__ = {"schema": "main"}
|
|
122
|
+
name = Column(String, nullable=False)
|
|
123
|
+
__tigrbl_tenant_policy__ = policy
|
|
124
|
+
|
|
125
|
+
from tigrbl.engine.shortcuts import mem
|
|
126
|
+
from tigrbl.engine.engine_spec import EngineSpec
|
|
127
|
+
from tigrbl.engine._engine import Engine
|
|
128
|
+
|
|
129
|
+
engine = Engine(EngineSpec.from_any(mem(async_=False)))
|
|
130
|
+
db_engine, _ = engine.raw()
|
|
131
|
+
Base.metadata.create_all(bind=db_engine)
|
|
132
|
+
|
|
133
|
+
with engine.session() as session:
|
|
134
|
+
session.execute(Tenant.__table__.insert().values(id=tenant_id, name="acme"))
|
|
135
|
+
for extra in extra_tenant_ids or []:
|
|
136
|
+
session.execute(Tenant.__table__.insert().values(id=extra, name="extra"))
|
|
137
|
+
session.commit()
|
|
138
|
+
|
|
139
|
+
authn = DummyAuth(user_id, tenant_id)
|
|
140
|
+
api = TigrblApp(engine=engine)
|
|
141
|
+
api.set_auth(authn=authn.get_principal)
|
|
142
|
+
api.include_models([Tenant, Item])
|
|
143
|
+
app = App()
|
|
144
|
+
app.include_router(api.router)
|
|
145
|
+
api.initialize()
|
|
146
|
+
return TestClient(app)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.i9n
|
|
150
|
+
def test_tenant_policy_runtime_switch():
|
|
151
|
+
user_id = uuid.uuid4()
|
|
152
|
+
tenant_id = uuid.uuid4()
|
|
153
|
+
headers = {"Authorization": "Bearer secret"}
|
|
154
|
+
|
|
155
|
+
client = _client_for_tenant(TenantPolicy.STRICT_SERVER, user_id, tenant_id)
|
|
156
|
+
res = client.post(
|
|
157
|
+
"/item",
|
|
158
|
+
json={"name": "one", "tenant_id": str(tenant_id)},
|
|
159
|
+
headers=headers,
|
|
160
|
+
)
|
|
161
|
+
assert res.status_code == 400
|
|
162
|
+
|
|
163
|
+
supplied = uuid.uuid4()
|
|
164
|
+
client = _client_for_tenant(
|
|
165
|
+
TenantPolicy.CLIENT_SET, user_id, tenant_id, extra_tenant_ids=[supplied]
|
|
166
|
+
)
|
|
167
|
+
res = client.post(
|
|
168
|
+
"/item",
|
|
169
|
+
json={"name": "two", "tenant_id": str(supplied)},
|
|
170
|
+
headers=headers,
|
|
171
|
+
)
|
|
172
|
+
assert res.status_code == 201
|
|
173
|
+
assert res.json()["tenant_id"] == str(supplied)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import pytest_asyncio
|
|
3
|
+
from tigrbl.types import App
|
|
4
|
+
from httpx import ASGITransport, AsyncClient
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from sqlalchemy import Column, String
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from tigrbl import TigrblApp, Base
|
|
10
|
+
from tigrbl.engine.shortcuts import mem
|
|
11
|
+
from tigrbl.schema import _build_schema
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest_asyncio.fixture()
|
|
15
|
+
async def api_client_with_extras(db_mode):
|
|
16
|
+
Base.metadata.clear()
|
|
17
|
+
|
|
18
|
+
class Widget(Base):
|
|
19
|
+
__tablename__ = "widgets"
|
|
20
|
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
|
21
|
+
name = Column(String, nullable=False)
|
|
22
|
+
__tigrbl_request_extras__ = {
|
|
23
|
+
"*": {"token": (str | None, Field(default=None, exclude=True))},
|
|
24
|
+
"create": {"create_note": (str | None, Field(default=None, exclude=True))},
|
|
25
|
+
"update": {"update_flag": (bool | None, Field(default=None, exclude=True))},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if db_mode == "async":
|
|
29
|
+
api = TigrblApp(engine=mem())
|
|
30
|
+
api.include_model(Widget)
|
|
31
|
+
await api.initialize()
|
|
32
|
+
else:
|
|
33
|
+
api = TigrblApp(engine=mem(async_=False))
|
|
34
|
+
api.include_model(Widget)
|
|
35
|
+
api.initialize()
|
|
36
|
+
|
|
37
|
+
app = App()
|
|
38
|
+
app.include_router(api.router)
|
|
39
|
+
transport = ASGITransport(app=app)
|
|
40
|
+
client = AsyncClient(transport=transport, base_url="http://test")
|
|
41
|
+
return client, api, Widget
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.i9n
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_request_extras_schema(api_client_with_extras):
|
|
47
|
+
_, _, Widget = api_client_with_extras
|
|
48
|
+
create_schema = _build_schema(Widget, verb="create")
|
|
49
|
+
update_schema = _build_schema(Widget, verb="update")
|
|
50
|
+
assert {"token", "create_note"} <= set(create_schema.model_fields)
|
|
51
|
+
assert {"token", "update_flag"} <= set(update_schema.model_fields)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.i9n
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_request_extras_runtime(api_client_with_extras):
|
|
57
|
+
client, _, _ = api_client_with_extras
|
|
58
|
+
res = await client.post(
|
|
59
|
+
"/widget",
|
|
60
|
+
json={"name": "w1", "token": "t", "create_note": "note"},
|
|
61
|
+
)
|
|
62
|
+
assert res.status_code == 201
|
|
63
|
+
body = res.json()
|
|
64
|
+
wid = body["id"]
|
|
65
|
+
assert "token" not in body and "create_note" not in body
|
|
66
|
+
|
|
67
|
+
res = await client.patch(
|
|
68
|
+
f"/widget/{wid}",
|
|
69
|
+
json={"name": "w2", "token": "t2", "update_flag": True},
|
|
70
|
+
)
|
|
71
|
+
assert res.status_code == 200
|
|
72
|
+
|
|
73
|
+
body = res.json()
|
|
74
|
+
assert "token" not in body and "update_flag" not in body
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from tigrbl import Base
|
|
4
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
5
|
+
from tigrbl.schema import _build_schema
|
|
6
|
+
from tigrbl.types import Column, Field, RequestExtrasProvider, String
|
|
7
|
+
from tigrbl.types.request_extras_provider import list_request_extras_providers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.i9n
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_request_extras_provider_in_schema():
|
|
13
|
+
Base.metadata.clear()
|
|
14
|
+
|
|
15
|
+
class Widget(Base, GUIDPk, RequestExtrasProvider):
|
|
16
|
+
__tablename__ = "widgets"
|
|
17
|
+
name = Column(String, nullable=False)
|
|
18
|
+
__tigrbl_request_extras__ = {
|
|
19
|
+
"create": {"extra": (int | None, Field(None, exclude=True))}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
SCreate = _build_schema(Widget, verb="create")
|
|
23
|
+
SRead = _build_schema(Widget, verb="read")
|
|
24
|
+
|
|
25
|
+
assert "extra" in SCreate.model_fields
|
|
26
|
+
assert "extra" not in SRead.model_fields
|
|
27
|
+
assert Widget in list_request_extras_providers()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from tigrbl import Base
|
|
4
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
5
|
+
from tigrbl.schema import _build_schema
|
|
6
|
+
from tigrbl.types import Column, Field, ResponseExtrasProvider, String
|
|
7
|
+
from tigrbl.types.response_extras_provider import list_response_extras_providers
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.i9n
|
|
11
|
+
@pytest.mark.asyncio
|
|
12
|
+
async def test_response_extras_provider_in_schema():
|
|
13
|
+
Base.metadata.clear()
|
|
14
|
+
|
|
15
|
+
class Widget(Base, GUIDPk, ResponseExtrasProvider):
|
|
16
|
+
__tablename__ = "widgets"
|
|
17
|
+
name = Column(String, nullable=False)
|
|
18
|
+
__tigrbl_response_extras__ = {"read": {"extra": (int | None, Field(None))}}
|
|
19
|
+
|
|
20
|
+
SRead = _build_schema(Widget, verb="read")
|
|
21
|
+
assert "extra" in SRead.model_fields
|
|
22
|
+
assert Widget in list_response_extras_providers()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import pytest_asyncio
|
|
3
|
+
from tigrbl.types import App
|
|
4
|
+
from httpx import ASGITransport, AsyncClient
|
|
5
|
+
from sqlalchemy import Integer, String
|
|
6
|
+
from sqlalchemy.orm import Mapped
|
|
7
|
+
|
|
8
|
+
from tigrbl import TigrblApp as Tigrblv3
|
|
9
|
+
from tigrbl.engine.shortcuts import mem
|
|
10
|
+
from tigrbl.specs import F, IO, S, acol
|
|
11
|
+
from tigrbl.orm.tables import Base as Base3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest_asyncio.fixture()
|
|
15
|
+
async def client_and_model():
|
|
16
|
+
Base3.metadata.clear()
|
|
17
|
+
|
|
18
|
+
class Widget(Base3):
|
|
19
|
+
__tablename__ = "widgets"
|
|
20
|
+
__allow_unmapped__ = True
|
|
21
|
+
|
|
22
|
+
id: Mapped[int] = acol(
|
|
23
|
+
storage=S(type_=Integer, primary_key=True, autoincrement=True),
|
|
24
|
+
io=IO(out_verbs=("read", "list")),
|
|
25
|
+
)
|
|
26
|
+
name: Mapped[str] = acol(
|
|
27
|
+
storage=S(type_=String, nullable=False),
|
|
28
|
+
field=F(required_in=("create",)),
|
|
29
|
+
io=IO(in_verbs=("create", "update")),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__tigrbl_cols__ = {"id": id, "name": name}
|
|
33
|
+
|
|
34
|
+
app = App()
|
|
35
|
+
api = Tigrblv3(engine=mem())
|
|
36
|
+
api.include_model(Widget, prefix="")
|
|
37
|
+
await api.initialize()
|
|
38
|
+
# Remove output schemas to trigger fallback serialization
|
|
39
|
+
Widget.schemas.read.out = None
|
|
40
|
+
Widget.schemas.list.out = None
|
|
41
|
+
|
|
42
|
+
app.include_router(api.router)
|
|
43
|
+
transport = ASGITransport(app=app)
|
|
44
|
+
client = AsyncClient(transport=transport, base_url="http://test")
|
|
45
|
+
try:
|
|
46
|
+
yield client, Widget
|
|
47
|
+
finally:
|
|
48
|
+
await client.aclose()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.i9n
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_rest_read_and_list_without_out_schema(client_and_model):
|
|
54
|
+
client, _ = client_and_model
|
|
55
|
+
created = await client.post("/widget", json={"name": "A"})
|
|
56
|
+
item_id = created.json()["id"]
|
|
57
|
+
|
|
58
|
+
resp = await client.get(f"/widget/{item_id}")
|
|
59
|
+
assert resp.status_code == 200
|
|
60
|
+
assert resp.json()["id"] == item_id
|
|
61
|
+
|
|
62
|
+
resp_list = await client.get("/widget")
|
|
63
|
+
assert resp_list.status_code == 200
|
|
64
|
+
data = resp_list.json()
|
|
65
|
+
assert isinstance(data, list)
|
|
66
|
+
assert data[0]["id"] == item_id
|