tigrbl-tests 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tests/__init__.py +0 -0
  2. tests/conftest.py +285 -0
  3. tests/i9n/__init__.py +0 -0
  4. tests/i9n/test_acronym_route_name.py +16 -0
  5. tests/i9n/test_allow_anon.py +239 -0
  6. tests/i9n/test_apikey_generation.py +47 -0
  7. tests/i9n/test_authn_provider_integration.py +67 -0
  8. tests/i9n/test_bindings_integration.py +108 -0
  9. tests/i9n/test_bindings_modules.py +149 -0
  10. tests/i9n/test_bulk_docs_client.py +99 -0
  11. tests/i9n/test_core_access.py +164 -0
  12. tests/i9n/test_error_mappings.py +360 -0
  13. tests/i9n/test_field_spec_effects.py +117 -0
  14. tests/i9n/test_header_io_uvicorn.py +71 -0
  15. tests/i9n/test_healthz_methodz_hookz.py +203 -0
  16. tests/i9n/test_hook_ctx_v3_i9n.py +392 -0
  17. tests/i9n/test_hook_lifecycle.py +452 -0
  18. tests/i9n/test_iospec_attributes.py +368 -0
  19. tests/i9n/test_iospec_integration.py +181 -0
  20. tests/i9n/test_key_digest_uvicorn.py +151 -0
  21. tests/i9n/test_list_filters_optional.py +20 -0
  22. tests/i9n/test_mixins.py +534 -0
  23. tests/i9n/test_nested_path_schema_and_rpc.py +34 -0
  24. tests/i9n/test_nested_routing_depth.py +118 -0
  25. tests/i9n/test_op_ctx_alias_examples.py +62 -0
  26. tests/i9n/test_op_ctx_behavior.py +395 -0
  27. tests/i9n/test_op_ctx_core_crud_order.py +219 -0
  28. tests/i9n/test_openapi_clear_response_schema.py +35 -0
  29. tests/i9n/test_openapi_schema_examples_presence.py +81 -0
  30. tests/i9n/test_opspec_effects_i9n_test.py +193 -0
  31. tests/i9n/test_owner_tenant_policy.py +173 -0
  32. tests/i9n/test_request_extras.py +74 -0
  33. tests/i9n/test_request_extras_provider.py +27 -0
  34. tests/i9n/test_response_extras_provider.py +22 -0
  35. tests/i9n/test_rest_fallback_serialization.py +66 -0
  36. tests/i9n/test_rest_row_serialization.py +81 -0
  37. tests/i9n/test_rest_rpc_parity_v3.py +0 -0
  38. tests/i9n/test_row_result_serialization.py +84 -0
  39. tests/i9n/test_schema.py +45 -0
  40. tests/i9n/test_schema_ctx_attributes_integration.py +178 -0
  41. tests/i9n/test_schema_ctx_op_ctx_integration.py +88 -0
  42. tests/i9n/test_schema_ctx_spec_integration.py +209 -0
  43. tests/i9n/test_sqlite_attachments.py +36 -0
  44. tests/i9n/test_storage_spec_integration.py +126 -0
  45. tests/i9n/test_symmetry_parity.py +26 -0
  46. tests/i9n/test_v3_bulk_rest_endpoints.py +120 -0
  47. tests/i9n/test_v3_default_rest_ops.py +145 -0
  48. tests/i9n/test_v3_default_rpc_ops.py +234 -0
  49. tests/i9n/test_v3_opspec_attributes.py +272 -0
  50. tests/i9n/test_verb_alias_policy.py +57 -0
  51. tests/i9n/uvicorn_utils.py +43 -0
  52. tests/perf/__init__.py +0 -0
  53. tests/perf/test_collect_caching.py +42 -0
  54. tests/perf/test_hookz_performance.py +89 -0
  55. tests/perf/test_methodz_performance.py +99 -0
  56. tests/unit/__init__.py +0 -0
  57. tests/unit/decorators/test_alias_ctx_bindings.py +34 -0
  58. tests/unit/decorators/test_engine_ctx_bindings.py +57 -0
  59. tests/unit/decorators/test_hook_ctx_bindings.py +53 -0
  60. tests/unit/decorators/test_op_alias_bindings.py +39 -0
  61. tests/unit/decorators/test_op_ctx_bindings.py +82 -0
  62. tests/unit/decorators/test_response_ctx_bindings.py +32 -0
  63. tests/unit/decorators/test_schema_ctx_bindings.py +39 -0
  64. tests/unit/response_utils.py +142 -0
  65. tests/unit/runtime/atoms/test_emit_paired_post.py +27 -0
  66. tests/unit/runtime/atoms/test_emit_paired_pre.py +38 -0
  67. tests/unit/runtime/atoms/test_emit_readtime_alias.py +41 -0
  68. tests/unit/runtime/atoms/test_out_masking.py +76 -0
  69. tests/unit/runtime/atoms/test_refresh_demand.py +43 -0
  70. tests/unit/runtime/atoms/test_resolve_assemble.py +45 -0
  71. tests/unit/runtime/atoms/test_resolve_paired_gen.py +65 -0
  72. tests/unit/runtime/atoms/test_schema_collect_in.py +44 -0
  73. tests/unit/runtime/atoms/test_schema_collect_out.py +43 -0
  74. tests/unit/runtime/atoms/test_storage_to_stored.py +45 -0
  75. tests/unit/runtime/atoms/test_wire_build_in.py +13 -0
  76. tests/unit/runtime/atoms/test_wire_build_out.py +40 -0
  77. tests/unit/runtime/atoms/test_wire_dump.py +14 -0
  78. tests/unit/runtime/atoms/test_wire_validate_in.py +69 -0
  79. tests/unit/runtime/test_events_phases.py +14 -0
  80. tests/unit/test_acol_vcol_knobs.py +96 -0
  81. tests/unit/test_alias_ctx_op_alias_attributes.py +75 -0
  82. tests/unit/test_alias_ctx_op_attributes.py +61 -0
  83. tests/unit/test_api_level_set_auth.py +25 -0
  84. tests/unit/test_app_model_defaults.py +28 -0
  85. tests/unit/test_app_reexport.py +6 -0
  86. tests/unit/test_base_facade_initialize.py +71 -0
  87. tests/unit/test_build_list_params_spec_model.py +33 -0
  88. tests/unit/test_bulk_body_annotation.py +23 -0
  89. tests/unit/test_bulk_response_schema.py +153 -0
  90. tests/unit/test_colspec_map_isolation.py +19 -0
  91. tests/unit/test_column_collect_mixins.py +21 -0
  92. tests/unit/test_column_rest_rpc_results.py +298 -0
  93. tests/unit/test_column_table_orm_binding.py +51 -0
  94. tests/unit/test_config_dataclass_none.py +12 -0
  95. tests/unit/test_core_crud_bulk_ops.py +160 -0
  96. tests/unit/test_core_crud_default_ops.py +174 -0
  97. tests/unit/test_core_crud_methods.py +337 -0
  98. tests/unit/test_core_wrap_memoization.py +67 -0
  99. tests/unit/test_db_dependency.py +19 -0
  100. tests/unit/test_decorator_and_collect.py +47 -0
  101. tests/unit/test_default_tags.py +20 -0
  102. tests/unit/test_engine_spec_and_shortcuts.py +84 -0
  103. tests/unit/test_engine_usage_levels.py +36 -0
  104. tests/unit/test_field_spec_attrs.py +95 -0
  105. tests/unit/test_file_response.py +148 -0
  106. tests/unit/test_handler_step_qualname.py +30 -0
  107. tests/unit/test_hook_ctx_attributes.py +33 -0
  108. tests/unit/test_hook_ctx_binding.py +55 -0
  109. tests/unit/test_hookz_empty_phase.py +37 -0
  110. tests/unit/test_hybrid_session_run_sync.py +18 -0
  111. tests/unit/test_in_tx.py +16 -0
  112. tests/unit/test_include_models_base_prefix.py +29 -0
  113. tests/unit/test_initialize_cross_ddl.py +26 -0
  114. tests/unit/test_io_spec_attributes.py +171 -0
  115. tests/unit/test_iospec_attributes.py +90 -0
  116. tests/unit/test_iospec_effects.py +173 -0
  117. tests/unit/test_jsonrpc_id_example.py +9 -0
  118. tests/unit/test_jsonrpc_router_default_tag.py +10 -0
  119. tests/unit/test_kernel_invoke_ctx.py +14 -0
  120. tests/unit/test_kernel_opview_on_demand.py +42 -0
  121. tests/unit/test_kernel_plan_labels.py +40 -0
  122. tests/unit/test_kernelz_endpoint.py +65 -0
  123. tests/unit/test_make_column_shortcuts.py +80 -0
  124. tests/unit/test_mixins_sqlalchemy.py +13 -0
  125. tests/unit/test_op_alias.py +70 -0
  126. tests/unit/test_op_class_engine_binding.py +38 -0
  127. tests/unit/test_op_ctx_arity_paths.py +83 -0
  128. tests/unit/test_op_ctx_attributes.py +147 -0
  129. tests/unit/test_op_ctx_core_crud_integration.py +376 -0
  130. tests/unit/test_op_ctx_dynamic_attach.py +19 -0
  131. tests/unit/test_op_ctx_persist_options.py +75 -0
  132. tests/unit/test_opspec_effects.py +153 -0
  133. tests/unit/test_postgres_engine_errors.py +17 -0
  134. tests/unit/test_postgres_env_vars.py +17 -0
  135. tests/unit/test_relationship_alias_cols.py +98 -0
  136. tests/unit/test_request_body_schema.py +54 -0
  137. tests/unit/test_request_response_examples.py +169 -0
  138. tests/unit/test_resolver_precedence.py +49 -0
  139. tests/unit/test_response_alias_table_rpc.py +40 -0
  140. tests/unit/test_response_ctx_precedence.py +62 -0
  141. tests/unit/test_response_diagnostics_kernelz.py +81 -0
  142. tests/unit/test_response_html_jinja_behavior.py +116 -0
  143. tests/unit/test_response_parity.py +20 -0
  144. tests/unit/test_response_rest.py +91 -0
  145. tests/unit/test_response_rpc.py +88 -0
  146. tests/unit/test_response_template.py +30 -0
  147. tests/unit/test_response_uuid.py +58 -0
  148. tests/unit/test_rest_all_default_op_verbs.py +58 -0
  149. tests/unit/test_rest_bulk_delete_suppresses_clear.py +25 -0
  150. tests/unit/test_rest_no_schema_jsonable.py +68 -0
  151. tests/unit/test_rest_operation_id_uniqueness.py +36 -0
  152. tests/unit/test_rest_rpc_parity_default_ops.py +86 -0
  153. tests/unit/test_rest_rpc_prefixes.py +40 -0
  154. tests/unit/test_rest_rpc_symmetry.py +151 -0
  155. tests/unit/test_rpc_all_default_op_verbs.py +224 -0
  156. tests/unit/test_rpc_default_ops.py +111 -0
  157. tests/unit/test_schema_ctx_attributes.py +96 -0
  158. tests/unit/test_schema_ctx_plain_class.py +34 -0
  159. tests/unit/test_schema_spec_presence.py +36 -0
  160. tests/unit/test_schemas_binding.py +29 -0
  161. tests/unit/test_security_per_route.py +43 -0
  162. tests/unit/test_should_wire_canonical.py +62 -0
  163. tests/unit/test_spec_api.py +50 -0
  164. tests/unit/test_spec_app.py +28 -0
  165. tests/unit/test_spec_column.py +24 -0
  166. tests/unit/test_spec_engine.py +61 -0
  167. tests/unit/test_spec_field.py +16 -0
  168. tests/unit/test_spec_hook.py +35 -0
  169. tests/unit/test_spec_io.py +29 -0
  170. tests/unit/test_spec_op.py +31 -0
  171. tests/unit/test_spec_storage.py +21 -0
  172. tests/unit/test_spec_table.py +21 -0
  173. tests/unit/test_sqlite_attachments.py +57 -0
  174. tests/unit/test_storage_spec_attributes.py +78 -0
  175. tests/unit/test_sys_handler_crud.py +86 -0
  176. tests/unit/test_sys_run_rollback.py +42 -0
  177. tests/unit/test_sys_tx_async_begin.py +45 -0
  178. tests/unit/test_sys_tx_begin.py +49 -0
  179. tests/unit/test_sys_tx_commit.py +59 -0
  180. tests/unit/test_table_base_exports.py +22 -0
  181. tests/unit/test_table_collect_spec.py +41 -0
  182. tests/unit/test_table_columns_namespace.py +21 -0
  183. tests/unit/test_v3_favicon_endpoint.py +17 -0
  184. tests/unit/test_v3_healthz_endpoint.py +39 -0
  185. tests/unit/test_v3_op_alias.py +88 -0
  186. tests/unit/test_v3_op_ctx_attributes.py +99 -0
  187. tests/unit/test_v3_schemas_and_decorators.py +114 -0
  188. tests/unit/test_v3_storage_spec_attributes.py +249 -0
  189. tigrbl_tests-0.3.0.dist-info/METADATA +103 -0
  190. tigrbl_tests-0.3.0.dist-info/RECORD +192 -0
  191. tigrbl_tests-0.3.0.dist-info/WHEEL +4 -0
  192. tigrbl_tests-0.3.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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}]