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,62 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from tigrbl import op_alias, op_ctx
|
|
4
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
5
|
+
from tigrbl.orm.tables import Base
|
|
6
|
+
from tigrbl.specs import F, S, acol
|
|
7
|
+
from tigrbl.types import Column, Mapped, String
|
|
8
|
+
from .test_op_ctx_behavior import setup_api
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.i9n
|
|
12
|
+
def test_op_ctx_alias_create_examples(sync_db_session):
|
|
13
|
+
_, get_db = sync_db_session
|
|
14
|
+
|
|
15
|
+
class Person(Base, GUIDPk):
|
|
16
|
+
__tablename__ = "people"
|
|
17
|
+
__resource__ = "person"
|
|
18
|
+
name: Mapped[str] = acol(
|
|
19
|
+
storage=S(String), field=F(constraints={"examples": ["Alice"]})
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@op_ctx(alias="register", target="create", arity="collection")
|
|
23
|
+
def register(cls, ctx): # pragma: no cover - logic irrelevant
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
app, _ = setup_api(Person, get_db)
|
|
27
|
+
spec = app.openapi()
|
|
28
|
+
_ = spec["paths"]["/person/register"]["post"]
|
|
29
|
+
req_props = spec["components"]["schemas"]["PersonRegisterRequest"]["properties"]
|
|
30
|
+
resp_props = spec["components"]["schemas"]["PersonRegisterResponse"]["properties"]
|
|
31
|
+
assert req_props["name"]["examples"][0] == "Alice"
|
|
32
|
+
assert resp_props["name"]["examples"][0] == "Alice"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.i9n
|
|
36
|
+
def test_op_ctx_alias_inherits_canonical_schemas(sync_db_session):
|
|
37
|
+
_, get_db = sync_db_session
|
|
38
|
+
|
|
39
|
+
class CreateReq(BaseModel):
|
|
40
|
+
info: str
|
|
41
|
+
|
|
42
|
+
class CreateResp(BaseModel):
|
|
43
|
+
info: str
|
|
44
|
+
|
|
45
|
+
@op_alias(
|
|
46
|
+
alias="create",
|
|
47
|
+
target="create",
|
|
48
|
+
request_model=CreateReq,
|
|
49
|
+
response_model=CreateResp,
|
|
50
|
+
)
|
|
51
|
+
class Person(Base, GUIDPk):
|
|
52
|
+
__tablename__ = "people2"
|
|
53
|
+
__resource__ = "person2"
|
|
54
|
+
name: Mapped[str] = Column(String)
|
|
55
|
+
|
|
56
|
+
@op_ctx(alias="register", target="create", arity="collection")
|
|
57
|
+
def register(cls, ctx): # pragma: no cover - logic irrelevant
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
setup_api(Person, get_db)
|
|
61
|
+
assert set(Person.schemas.register.in_.model_fields) == {"info"}
|
|
62
|
+
assert set(Person.schemas.register.out.model_fields) == {"info"}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from httpx import ASGITransport, AsyncClient
|
|
3
|
+
from tigrbl.types import App, BaseModel, Column, String, UUID
|
|
4
|
+
|
|
5
|
+
from tigrbl import TigrblApp, op_ctx, schema_ctx, hook_ctx
|
|
6
|
+
from tigrbl.orm.tables import Base
|
|
7
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
8
|
+
from tigrbl.runtime.kernel import build_phase_chains
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# helper to set up Tigrbl with sync DB from fixture
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def setup_api(model_cls, get_db):
|
|
15
|
+
Base.metadata.clear()
|
|
16
|
+
app = App()
|
|
17
|
+
api = TigrblApp(get_db=get_db)
|
|
18
|
+
api.include_model(model_cls, prefix="")
|
|
19
|
+
api.initialize()
|
|
20
|
+
app.include_router(api.router)
|
|
21
|
+
return app, api
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.i9n
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_op_ctx_request_response_schemas(sync_db_session):
|
|
27
|
+
_, get_sync_db = sync_db_session
|
|
28
|
+
|
|
29
|
+
class Widget(Base, GUIDPk):
|
|
30
|
+
__tablename__ = "widgets"
|
|
31
|
+
__resource__ = "widget"
|
|
32
|
+
name = Column(String)
|
|
33
|
+
|
|
34
|
+
@schema_ctx(alias="Echo", kind="in")
|
|
35
|
+
class EchoIn(BaseModel):
|
|
36
|
+
text: str
|
|
37
|
+
|
|
38
|
+
@schema_ctx(alias="Echo", kind="out")
|
|
39
|
+
class EchoOut(BaseModel):
|
|
40
|
+
text: str
|
|
41
|
+
|
|
42
|
+
@op_ctx(
|
|
43
|
+
alias="echo",
|
|
44
|
+
target="custom",
|
|
45
|
+
arity="collection",
|
|
46
|
+
request_schema="Echo.in",
|
|
47
|
+
response_schema="Echo.out",
|
|
48
|
+
)
|
|
49
|
+
def echo(cls, ctx):
|
|
50
|
+
payload = ctx.get("payload") or {}
|
|
51
|
+
return {"text": str(payload.get("text"))}
|
|
52
|
+
|
|
53
|
+
app, api = setup_api(Widget, get_sync_db)
|
|
54
|
+
|
|
55
|
+
async with AsyncClient(
|
|
56
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
57
|
+
) as client:
|
|
58
|
+
res = await client.post("/widget/echo", json={"text": "123"})
|
|
59
|
+
assert res.status_code == 200
|
|
60
|
+
assert res.json() == {"text": "123"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.i9n
|
|
64
|
+
def test_op_ctx_columns(sync_db_session):
|
|
65
|
+
_, get_sync_db = sync_db_session
|
|
66
|
+
|
|
67
|
+
class Gadget(Base, GUIDPk):
|
|
68
|
+
__tablename__ = "gadgets"
|
|
69
|
+
__resource__ = "gadget"
|
|
70
|
+
name = Column(String)
|
|
71
|
+
flag = Column(String)
|
|
72
|
+
|
|
73
|
+
@op_ctx(alias="ping", target="custom", arity="collection")
|
|
74
|
+
def ping(cls, ctx):
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
_, api = setup_api(Gadget, get_sync_db)
|
|
78
|
+
assert set(api.columns["Gadget"]) == {"id", "name", "flag"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.i9n
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_op_ctx_defaults_value_resolution(sync_db_session):
|
|
84
|
+
_, get_sync_db = sync_db_session
|
|
85
|
+
|
|
86
|
+
class Thing(Base, GUIDPk):
|
|
87
|
+
__tablename__ = "things"
|
|
88
|
+
__resource__ = "thing"
|
|
89
|
+
name = Column(String)
|
|
90
|
+
status = Column(String, default="new")
|
|
91
|
+
|
|
92
|
+
@op_ctx(alias="make", target="create")
|
|
93
|
+
def make(cls, ctx):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
app, api = setup_api(Thing, get_sync_db)
|
|
97
|
+
|
|
98
|
+
async with AsyncClient(
|
|
99
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
100
|
+
) as client:
|
|
101
|
+
res = await client.post("/thing", json={"name": "a"})
|
|
102
|
+
assert res.status_code == 201
|
|
103
|
+
item_id = UUID(res.json()["id"])
|
|
104
|
+
assert res.json()["status"] == "new"
|
|
105
|
+
|
|
106
|
+
gen = get_sync_db()
|
|
107
|
+
session = next(gen)
|
|
108
|
+
obj = session.get(Thing, item_id)
|
|
109
|
+
assert obj.status == "new"
|
|
110
|
+
try:
|
|
111
|
+
next(gen)
|
|
112
|
+
except StopIteration:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.i9n
|
|
117
|
+
@pytest.mark.asyncio
|
|
118
|
+
async def test_op_ctx_internal_orm_models(sync_db_session):
|
|
119
|
+
_, get_sync_db = sync_db_session
|
|
120
|
+
|
|
121
|
+
class Item(Base, GUIDPk):
|
|
122
|
+
__tablename__ = "items"
|
|
123
|
+
__resource__ = "item"
|
|
124
|
+
name = Column(String)
|
|
125
|
+
|
|
126
|
+
@op_ctx(alias="seed", target="create")
|
|
127
|
+
def seed(cls, ctx):
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
app, api = setup_api(Item, get_sync_db)
|
|
131
|
+
|
|
132
|
+
async with AsyncClient(
|
|
133
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
134
|
+
) as client:
|
|
135
|
+
res = await client.post("/item", json={"name": "a"})
|
|
136
|
+
assert res.status_code == 201
|
|
137
|
+
item_id = UUID(res.json()["id"])
|
|
138
|
+
|
|
139
|
+
assert api.models["Item"] is Item
|
|
140
|
+
gen = get_sync_db()
|
|
141
|
+
session = next(gen)
|
|
142
|
+
assert isinstance(session.get(Item, item_id), Item)
|
|
143
|
+
try:
|
|
144
|
+
next(gen)
|
|
145
|
+
except StopIteration:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.i9n
|
|
150
|
+
def test_op_ctx_openapi_json(sync_db_session):
|
|
151
|
+
_, get_sync_db = sync_db_session
|
|
152
|
+
|
|
153
|
+
class Widget(Base, GUIDPk):
|
|
154
|
+
__tablename__ = "widgets"
|
|
155
|
+
__resource__ = "widget"
|
|
156
|
+
name = Column(String)
|
|
157
|
+
|
|
158
|
+
@op_ctx(alias="ping", target="custom", arity="collection")
|
|
159
|
+
def ping(cls, ctx):
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
app, _ = setup_api(Widget, get_sync_db)
|
|
163
|
+
spec = app.openapi()
|
|
164
|
+
assert "/widget/ping" in spec["paths"]
|
|
165
|
+
assert "post" in spec["paths"]["/widget/ping"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.i9n
|
|
169
|
+
@pytest.mark.asyncio
|
|
170
|
+
async def test_op_ctx_preserves_canon_schemas(sync_db_session):
|
|
171
|
+
_, get_sync_db = sync_db_session
|
|
172
|
+
|
|
173
|
+
class RegisterIn(BaseModel):
|
|
174
|
+
name: str
|
|
175
|
+
|
|
176
|
+
class TokenPair(BaseModel):
|
|
177
|
+
access: str
|
|
178
|
+
|
|
179
|
+
class Widget(Base, GUIDPk):
|
|
180
|
+
__tablename__ = "widgets"
|
|
181
|
+
__resource__ = "widget"
|
|
182
|
+
name = Column(String)
|
|
183
|
+
|
|
184
|
+
@op_ctx(
|
|
185
|
+
alias="register",
|
|
186
|
+
target="custom",
|
|
187
|
+
arity="collection",
|
|
188
|
+
request_schema=RegisterIn,
|
|
189
|
+
response_schema=TokenPair,
|
|
190
|
+
)
|
|
191
|
+
def register(cls, ctx):
|
|
192
|
+
return TokenPair(access="x")
|
|
193
|
+
|
|
194
|
+
app, _ = setup_api(Widget, get_sync_db)
|
|
195
|
+
spec = app.openapi()
|
|
196
|
+
schemas = spec["components"]["schemas"].keys()
|
|
197
|
+
assert "WidgetCreateRequest" in schemas
|
|
198
|
+
assert "WidgetCreateResponse" in schemas
|
|
199
|
+
assert "WidgetRegisterRequest" in schemas
|
|
200
|
+
assert "WidgetRegisterResponse" in schemas
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@pytest.mark.i9n
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_op_ctx_storage_sqlalchemy(sync_db_session):
|
|
206
|
+
_, get_sync_db = sync_db_session
|
|
207
|
+
|
|
208
|
+
class Widget(Base, GUIDPk):
|
|
209
|
+
__tablename__ = "widgets"
|
|
210
|
+
__resource__ = "widget"
|
|
211
|
+
name = Column(String)
|
|
212
|
+
|
|
213
|
+
@op_ctx(alias="make", target="create")
|
|
214
|
+
def make(cls, ctx):
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
app, _ = setup_api(Widget, get_sync_db)
|
|
218
|
+
|
|
219
|
+
async with AsyncClient(
|
|
220
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
221
|
+
) as client:
|
|
222
|
+
res = await client.post("/widget", json={"name": "w"})
|
|
223
|
+
assert res.status_code == 201
|
|
224
|
+
item_id = UUID(res.json()["id"])
|
|
225
|
+
|
|
226
|
+
gen = get_sync_db()
|
|
227
|
+
session = next(gen)
|
|
228
|
+
obj = session.get(Widget, item_id)
|
|
229
|
+
assert obj is not None
|
|
230
|
+
try:
|
|
231
|
+
next(gen)
|
|
232
|
+
except StopIteration:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.i9n
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_op_ctx_rest_call(sync_db_session):
|
|
239
|
+
_, get_sync_db = sync_db_session
|
|
240
|
+
|
|
241
|
+
class Gadget(Base, GUIDPk):
|
|
242
|
+
__tablename__ = "gadgets"
|
|
243
|
+
__resource__ = "gadget"
|
|
244
|
+
name = Column(String)
|
|
245
|
+
|
|
246
|
+
@schema_ctx(alias="Ping", kind="out")
|
|
247
|
+
class PingOut(BaseModel):
|
|
248
|
+
msg: str
|
|
249
|
+
|
|
250
|
+
@op_ctx(
|
|
251
|
+
alias="ping",
|
|
252
|
+
target="custom",
|
|
253
|
+
arity="collection",
|
|
254
|
+
response_schema="Ping.out",
|
|
255
|
+
)
|
|
256
|
+
def ping(cls, ctx):
|
|
257
|
+
return {"msg": "ok"}
|
|
258
|
+
|
|
259
|
+
app, _ = setup_api(Gadget, get_sync_db)
|
|
260
|
+
|
|
261
|
+
async with AsyncClient(
|
|
262
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
263
|
+
) as client:
|
|
264
|
+
res = await client.post("/gadget/ping", json={})
|
|
265
|
+
assert res.status_code == 200
|
|
266
|
+
assert res.json() == {"msg": "ok"}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pytest.mark.i9n
|
|
270
|
+
@pytest.mark.asyncio
|
|
271
|
+
async def test_op_ctx_rpc_method(sync_db_session):
|
|
272
|
+
_, get_sync_db = sync_db_session
|
|
273
|
+
|
|
274
|
+
class Widget(Base, GUIDPk):
|
|
275
|
+
__tablename__ = "widgets"
|
|
276
|
+
__resource__ = "widget"
|
|
277
|
+
name = Column(String)
|
|
278
|
+
|
|
279
|
+
@op_ctx(alias="ping", target="custom", arity="collection")
|
|
280
|
+
def ping(cls, ctx):
|
|
281
|
+
return {"ok": True}
|
|
282
|
+
|
|
283
|
+
app, api = setup_api(Widget, get_sync_db)
|
|
284
|
+
api.mount_jsonrpc(prefix="/rpc")
|
|
285
|
+
app.include_router(api.router)
|
|
286
|
+
|
|
287
|
+
async with AsyncClient(
|
|
288
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
289
|
+
) as client:
|
|
290
|
+
payload = {"jsonrpc": "2.0", "method": "Widget.ping", "params": {}, "id": 1}
|
|
291
|
+
res = await client.post("/rpc", json=payload, follow_redirects=True)
|
|
292
|
+
assert res.status_code == 200
|
|
293
|
+
assert res.json()["result"] == {"ok": True}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@pytest.mark.i9n
|
|
297
|
+
@pytest.mark.asyncio
|
|
298
|
+
async def test_op_ctx_core_crud(sync_db_session):
|
|
299
|
+
_, get_sync_db = sync_db_session
|
|
300
|
+
|
|
301
|
+
class Widget(Base, GUIDPk):
|
|
302
|
+
__tablename__ = "widgets"
|
|
303
|
+
__resource__ = "widget"
|
|
304
|
+
name = Column(String)
|
|
305
|
+
|
|
306
|
+
@op_ctx(alias="fetch", target="read", arity="member")
|
|
307
|
+
def fetch(cls, ctx, obj):
|
|
308
|
+
return obj
|
|
309
|
+
|
|
310
|
+
app, _ = setup_api(Widget, get_sync_db)
|
|
311
|
+
|
|
312
|
+
async with AsyncClient(
|
|
313
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
314
|
+
) as client:
|
|
315
|
+
r1 = await client.post("/widget", json={"name": "w"})
|
|
316
|
+
wid = UUID(r1.json()["id"]) # capture id as UUID
|
|
317
|
+
r2 = await client.get(f"/widget/{wid}")
|
|
318
|
+
assert r2.status_code == 200
|
|
319
|
+
assert r2.json()["name"] == "w"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@pytest.mark.i9n
|
|
323
|
+
@pytest.mark.asyncio
|
|
324
|
+
async def test_op_ctx_hookz(sync_db_session):
|
|
325
|
+
_, get_sync_db = sync_db_session
|
|
326
|
+
calls = []
|
|
327
|
+
|
|
328
|
+
class Widget(Base, GUIDPk):
|
|
329
|
+
__tablename__ = "widgets"
|
|
330
|
+
__resource__ = "widget"
|
|
331
|
+
name = Column(String)
|
|
332
|
+
|
|
333
|
+
@schema_ctx(alias="Echo", kind="out")
|
|
334
|
+
class EchoOut(BaseModel):
|
|
335
|
+
msg: str
|
|
336
|
+
|
|
337
|
+
@op_ctx(
|
|
338
|
+
alias="echo",
|
|
339
|
+
target="custom",
|
|
340
|
+
arity="collection",
|
|
341
|
+
response_schema="Echo.out",
|
|
342
|
+
)
|
|
343
|
+
def echo(cls, ctx):
|
|
344
|
+
return {"msg": "hi"}
|
|
345
|
+
|
|
346
|
+
@hook_ctx(ops="echo", phase="POST_HANDLER")
|
|
347
|
+
async def record(cls, ctx):
|
|
348
|
+
calls.append("hooked")
|
|
349
|
+
|
|
350
|
+
app, _ = setup_api(Widget, get_sync_db)
|
|
351
|
+
|
|
352
|
+
async with AsyncClient(
|
|
353
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
354
|
+
) as client:
|
|
355
|
+
await client.post("/widget/echo", json={})
|
|
356
|
+
assert calls.count("hooked") >= 1
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@pytest.mark.i9n
|
|
360
|
+
def test_op_ctx_atom_plan(sync_db_session):
|
|
361
|
+
_, get_sync_db = sync_db_session
|
|
362
|
+
|
|
363
|
+
class Widget(Base, GUIDPk):
|
|
364
|
+
__tablename__ = "widgets"
|
|
365
|
+
__resource__ = "widget"
|
|
366
|
+
name = Column(String)
|
|
367
|
+
|
|
368
|
+
@op_ctx(alias="make", target="create")
|
|
369
|
+
def make(cls, ctx):
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
_, api = setup_api(Widget, get_sync_db)
|
|
373
|
+
chains = build_phase_chains(Widget, "make")
|
|
374
|
+
names = [fn.__name__ for funcs in chains.values() for fn in funcs]
|
|
375
|
+
assert "create" in names
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@pytest.mark.i9n
|
|
379
|
+
def test_op_ctx_system_steps(sync_db_session):
|
|
380
|
+
_, get_sync_db = sync_db_session
|
|
381
|
+
|
|
382
|
+
class Widget(Base, GUIDPk):
|
|
383
|
+
__tablename__ = "widgets"
|
|
384
|
+
__resource__ = "widget"
|
|
385
|
+
name = Column(String)
|
|
386
|
+
|
|
387
|
+
@op_ctx(alias="ping", target="custom", arity="collection")
|
|
388
|
+
def ping(cls, ctx):
|
|
389
|
+
return {}
|
|
390
|
+
|
|
391
|
+
_, api = setup_api(Widget, get_sync_db)
|
|
392
|
+
chains = build_phase_chains(Widget, "ping")
|
|
393
|
+
|
|
394
|
+
assert chains["START_TX"]
|
|
395
|
+
assert chains["END_TX"]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from tigrbl.types import App
|
|
3
|
+
from httpx import ASGITransport, AsyncClient
|
|
4
|
+
from sqlalchemy import Column, String
|
|
5
|
+
|
|
6
|
+
from tigrbl import TigrblApp, op_ctx
|
|
7
|
+
from tigrbl.orm.tables import Base
|
|
8
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
9
|
+
from tigrbl.core import crud
|
|
10
|
+
from tigrbl import core as _core
|
|
11
|
+
from tigrbl.engine.shortcuts import mem
|
|
12
|
+
from tigrbl.engine.engine_spec import EngineSpec
|
|
13
|
+
from tigrbl.engine._engine import Engine
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_api(model_cls):
|
|
17
|
+
Base.metadata.clear()
|
|
18
|
+
spec = EngineSpec.from_any(mem(async_=False))
|
|
19
|
+
engine = Engine(spec)
|
|
20
|
+
app = App(engine=engine)
|
|
21
|
+
api = TigrblApp(engine=engine)
|
|
22
|
+
api.include_model(model_cls, prefix="")
|
|
23
|
+
api.initialize()
|
|
24
|
+
app.include_router(api.router)
|
|
25
|
+
return app, engine
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def fetch_inspection(client):
|
|
29
|
+
openapi = (await client.get("/openapi.json")).json()
|
|
30
|
+
hookz = (await client.get("/hookz")).json()
|
|
31
|
+
kernelz = (await client.get("/kernelz")).json()
|
|
32
|
+
return openapi, hookz, kernelz
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.i9n
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
@pytest.mark.parametrize(
|
|
38
|
+
"verb,alias,http_method,arity,needs_id,expected_status",
|
|
39
|
+
[
|
|
40
|
+
("create", "make", "post", None, False, 201),
|
|
41
|
+
("read", "fetch", "get", "member", True, 404),
|
|
42
|
+
("update", "change", "patch", "member", True, 404),
|
|
43
|
+
("delete", "remove", "delete", "member", True, 404),
|
|
44
|
+
("list", "browse", "get", "collection", False, 400),
|
|
45
|
+
("clear", "purge", "delete", "collection", False, 400),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
async def test_op_ctx_alias(
|
|
49
|
+
monkeypatch,
|
|
50
|
+
verb,
|
|
51
|
+
alias,
|
|
52
|
+
http_method,
|
|
53
|
+
arity,
|
|
54
|
+
needs_id,
|
|
55
|
+
expected_status,
|
|
56
|
+
):
|
|
57
|
+
calls: list[str] = []
|
|
58
|
+
orig = getattr(_core, verb)
|
|
59
|
+
|
|
60
|
+
async def wrapped(*args, **kwargs):
|
|
61
|
+
calls.append("core")
|
|
62
|
+
return await orig(*args, **kwargs)
|
|
63
|
+
|
|
64
|
+
# Patch both the re-exported core function and the underlying crud module
|
|
65
|
+
monkeypatch.setattr(_core, verb, wrapped)
|
|
66
|
+
monkeypatch.setattr(crud, verb, wrapped)
|
|
67
|
+
|
|
68
|
+
class Widget(Base, GUIDPk):
|
|
69
|
+
__tablename__ = "widgets"
|
|
70
|
+
__resource__ = "widget"
|
|
71
|
+
name = Column(String)
|
|
72
|
+
|
|
73
|
+
@op_ctx(alias=alias, target=verb, arity=arity)
|
|
74
|
+
def _(cls, ctx): # pragma: no cover - handler not invoked
|
|
75
|
+
calls.append("op")
|
|
76
|
+
if verb == "update" and ctx.get("obj"):
|
|
77
|
+
ctx["obj"].name = "b"
|
|
78
|
+
if verb == "clear":
|
|
79
|
+
ctx["result"] = {"cleared": True}
|
|
80
|
+
return ctx.get("obj") or ctx.get("result")
|
|
81
|
+
|
|
82
|
+
app, engine = setup_api(Widget)
|
|
83
|
+
get_sync_db = engine.get_db
|
|
84
|
+
|
|
85
|
+
async with AsyncClient(
|
|
86
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
87
|
+
) as client:
|
|
88
|
+
wid = None
|
|
89
|
+
if needs_id or verb in {"update", "delete", "list", "clear"}:
|
|
90
|
+
r = await client.post("/widget", json={"name": "a"})
|
|
91
|
+
wid = r.json()["id"]
|
|
92
|
+
path = f"/widget/{wid}/{alias}" if needs_id else f"/widget/{alias}"
|
|
93
|
+
body = (
|
|
94
|
+
{"name": "b"}
|
|
95
|
+
if verb == "update"
|
|
96
|
+
else {"name": "a"}
|
|
97
|
+
if verb == "create"
|
|
98
|
+
else None
|
|
99
|
+
)
|
|
100
|
+
if http_method == "post":
|
|
101
|
+
res = await client.post(path, json=body)
|
|
102
|
+
elif http_method == "get":
|
|
103
|
+
res = await client.get(path)
|
|
104
|
+
elif http_method == "patch":
|
|
105
|
+
res = await client.patch(path, json=body)
|
|
106
|
+
elif http_method == "put":
|
|
107
|
+
res = await client.put(path, json=body)
|
|
108
|
+
else:
|
|
109
|
+
res = await client.delete(path)
|
|
110
|
+
assert res.status_code == expected_status
|
|
111
|
+
|
|
112
|
+
gen = get_sync_db()
|
|
113
|
+
session = next(gen)
|
|
114
|
+
count = session.query(Widget).count()
|
|
115
|
+
obj = session.query(Widget).first()
|
|
116
|
+
if verb == "create":
|
|
117
|
+
assert count == 1
|
|
118
|
+
elif verb == "update":
|
|
119
|
+
assert obj.name == "a"
|
|
120
|
+
elif verb == "delete":
|
|
121
|
+
assert count == 1
|
|
122
|
+
elif verb == "clear":
|
|
123
|
+
assert count == 1
|
|
124
|
+
else:
|
|
125
|
+
assert count == 1
|
|
126
|
+
try:
|
|
127
|
+
next(gen)
|
|
128
|
+
except StopIteration:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
openapi, _, _ = await fetch_inspection(client)
|
|
132
|
+
assert path not in openapi["paths"]
|
|
133
|
+
|
|
134
|
+
if verb == "create":
|
|
135
|
+
# Creating via alias still invokes the core creator
|
|
136
|
+
assert calls == ["op", "core"]
|
|
137
|
+
else:
|
|
138
|
+
assert calls == []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.i9n
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
@pytest.mark.parametrize(
|
|
144
|
+
"verb,http_method,arity,needs_id",
|
|
145
|
+
[
|
|
146
|
+
("create", "post", None, False),
|
|
147
|
+
("read", "get", "member", True),
|
|
148
|
+
("update", "patch", "member", True),
|
|
149
|
+
("delete", "delete", "member", True),
|
|
150
|
+
("list", "get", "collection", False),
|
|
151
|
+
("clear", "delete", "collection", False),
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
async def test_op_ctx_override(verb, http_method, arity, needs_id):
|
|
155
|
+
class Widget(Base, GUIDPk):
|
|
156
|
+
__tablename__ = "widgets"
|
|
157
|
+
__resource__ = "widget"
|
|
158
|
+
name = Column(String)
|
|
159
|
+
|
|
160
|
+
@op_ctx(target=verb, arity=arity)
|
|
161
|
+
def _(cls, ctx): # pragma: no cover - handler not invoked
|
|
162
|
+
ctx["result"] = {"custom": True}
|
|
163
|
+
return ctx["result"]
|
|
164
|
+
|
|
165
|
+
app, engine = setup_api(Widget)
|
|
166
|
+
get_sync_db = engine.get_db
|
|
167
|
+
|
|
168
|
+
async with AsyncClient(
|
|
169
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
170
|
+
) as client:
|
|
171
|
+
wid = None
|
|
172
|
+
if needs_id or verb in {"update", "delete", "list", "clear"}:
|
|
173
|
+
r = await client.post("/widget", json={"name": "a"})
|
|
174
|
+
wid = r.json()["id"]
|
|
175
|
+
path = f"/widget/{wid}" if needs_id else "/widget"
|
|
176
|
+
body = (
|
|
177
|
+
{"name": "b"}
|
|
178
|
+
if verb == "update"
|
|
179
|
+
else {"name": "a"}
|
|
180
|
+
if verb == "create"
|
|
181
|
+
else None
|
|
182
|
+
)
|
|
183
|
+
if http_method == "post":
|
|
184
|
+
res = await client.post(path, json=body)
|
|
185
|
+
elif http_method == "get":
|
|
186
|
+
res = await client.get(path)
|
|
187
|
+
elif http_method == "patch":
|
|
188
|
+
res = await client.patch(path, json=body)
|
|
189
|
+
elif http_method == "put":
|
|
190
|
+
res = await client.put(path, json=body)
|
|
191
|
+
else:
|
|
192
|
+
res = await client.delete(path)
|
|
193
|
+
assert res.status_code in {200, 201}
|
|
194
|
+
|
|
195
|
+
gen = get_sync_db()
|
|
196
|
+
session = next(gen)
|
|
197
|
+
count = session.query(Widget).count()
|
|
198
|
+
if verb == "create":
|
|
199
|
+
assert count == 1
|
|
200
|
+
elif verb == "read":
|
|
201
|
+
assert count == 1 if wid else 0
|
|
202
|
+
elif verb == "update":
|
|
203
|
+
obj = session.query(Widget).first()
|
|
204
|
+
# Overriding the update target bypasses the core updater
|
|
205
|
+
assert obj.name == "a"
|
|
206
|
+
elif verb == "delete":
|
|
207
|
+
assert count == 0
|
|
208
|
+
elif verb == "list":
|
|
209
|
+
assert count == 1
|
|
210
|
+
elif verb == "clear":
|
|
211
|
+
assert count == 0
|
|
212
|
+
try:
|
|
213
|
+
next(gen)
|
|
214
|
+
except StopIteration:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
openapi, _, _ = await fetch_inspection(client)
|
|
218
|
+
template = "/widget/{item_id}" if needs_id else "/widget"
|
|
219
|
+
assert template in openapi["paths"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from httpx import ASGITransport, AsyncClient
|
|
3
|
+
|
|
4
|
+
from tigrbl import TigrblApp
|
|
5
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
6
|
+
from tigrbl.orm.tables import Base
|
|
7
|
+
from tigrbl.types import App, Column, String
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.mark.asyncio()
|
|
11
|
+
async def test_openapi_clear_response_schema() -> None:
|
|
12
|
+
Base.metadata.clear()
|
|
13
|
+
|
|
14
|
+
class Widget(Base, GUIDPk):
|
|
15
|
+
__tablename__ = "widgets_openapi_clear"
|
|
16
|
+
name = Column(String, nullable=False)
|
|
17
|
+
|
|
18
|
+
app = App()
|
|
19
|
+
api = TigrblApp()
|
|
20
|
+
api.include_model(Widget)
|
|
21
|
+
app.include_router(api.router)
|
|
22
|
+
|
|
23
|
+
async with AsyncClient(
|
|
24
|
+
transport=ASGITransport(app=app), base_url="http://test"
|
|
25
|
+
) as client:
|
|
26
|
+
spec = (await client.get("/openapi.json")).json()
|
|
27
|
+
|
|
28
|
+
path = f"/{Widget.__name__.lower()}"
|
|
29
|
+
schema_ref = spec["paths"][path]["delete"]["responses"]["200"]["content"][
|
|
30
|
+
"application/json"
|
|
31
|
+
]["schema"]["$ref"]
|
|
32
|
+
assert schema_ref.endswith("WidgetClearResponse")
|
|
33
|
+
comp = spec["components"]["schemas"]["WidgetClearResponse"]
|
|
34
|
+
assert "deleted" in comp.get("properties", {})
|
|
35
|
+
assert comp.get("examples") == [{"deleted": 0}]
|