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,452 @@
1
+ """
2
+ Hook Lifecycle Tests for Tigrbl v3
3
+
4
+ Tests all hook phases and their behavior across CRUD, nested CRUD, and RPC operations.
5
+ """
6
+
7
+ import pytest
8
+ from fastapi import FastAPI
9
+ from tigrbl import TigrblApp, Base
10
+ from tigrbl.hook import hook_ctx
11
+ from tigrbl.engine.shortcuts import mem
12
+ from tigrbl.orm.mixins import GUIDPk
13
+ from httpx import ASGITransport, AsyncClient
14
+ from sqlalchemy import Column, ForeignKey, String
15
+ from tigrbl.types import PgUUID
16
+
17
+
18
+ async def setup_client(db_mode, Tenant, Item):
19
+ """Create an Tigrbl client for the provided models."""
20
+ fastapi_app = FastAPI()
21
+
22
+ if db_mode == "async":
23
+ api = TigrblApp(engine=mem())
24
+ api.include_models([Tenant, Item])
25
+ await api.initialize()
26
+ else:
27
+ api = TigrblApp(engine=mem(async_=False))
28
+ api.include_models([Tenant, Item])
29
+ api.initialize()
30
+
31
+ api.mount_jsonrpc()
32
+ fastapi_app.include_router(api.router)
33
+ transport = ASGITransport(app=fastapi_app)
34
+ client = AsyncClient(transport=transport, base_url="http://test")
35
+ return client, api
36
+
37
+
38
+ @pytest.mark.i9n
39
+ @pytest.mark.asyncio
40
+ async def test_hook_phases_execution_order(db_mode):
41
+ """Test that all hook phases execute in the correct order."""
42
+
43
+ execution_order: list[str] = []
44
+ Base.metadata.clear()
45
+
46
+ class Tenant(Base, GUIDPk):
47
+ __tablename__ = "tenants"
48
+ name = Column(String, nullable=False)
49
+
50
+ class Item(Base, GUIDPk):
51
+ __tablename__ = "items"
52
+ tenant_id = Column(
53
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
54
+ )
55
+ name = Column(String, nullable=False)
56
+
57
+ @hook_ctx(ops="create", phase="PRE_TX_BEGIN")
58
+ async def pre_tx_begin(cls, ctx):
59
+ execution_order.append("PRE_TX_BEGIN")
60
+ ctx["test_data"] = {"started": True}
61
+
62
+ @hook_ctx(ops="create", phase="PRE_HANDLER")
63
+ async def pre_handler(cls, ctx):
64
+ execution_order.append("PRE_HANDLER")
65
+ assert ctx["test_data"]["started"] is True
66
+ ctx["test_data"]["pre_handler_done"] = True
67
+
68
+ @hook_ctx(ops="create", phase="POST_HANDLER")
69
+ async def post_handler(cls, ctx):
70
+ execution_order.append("POST_HANDLER")
71
+ assert ctx["test_data"]["pre_handler_done"] is True
72
+ ctx["test_data"]["handler_done"] = True
73
+
74
+ @hook_ctx(ops="create", phase="PRE_COMMIT")
75
+ async def pre_commit(cls, ctx):
76
+ execution_order.append("PRE_COMMIT")
77
+ assert ctx["test_data"]["handler_done"] is True
78
+ ctx["test_data"]["pre_commit_done"] = True
79
+
80
+ @hook_ctx(ops="create", phase="POST_COMMIT")
81
+ async def post_commit(cls, ctx):
82
+ execution_order.append("POST_COMMIT")
83
+ assert ctx["test_data"]["pre_commit_done"] is True
84
+ ctx["test_data"]["committed"] = True
85
+
86
+ @hook_ctx(ops="create", phase="POST_RESPONSE")
87
+ async def post_response(cls, ctx):
88
+ execution_order.append("POST_RESPONSE")
89
+ assert ctx["test_data"]["committed"] is True
90
+ ctx["response"].result["hook_completed"] = True
91
+
92
+ client, _ = await setup_client(db_mode, Tenant, Item)
93
+
94
+ t = await client.post("/tenant", json={"name": "test-tenant"})
95
+ tid = t.json()["id"]
96
+
97
+ res = await client.post(
98
+ "/rpc",
99
+ json={
100
+ "method": "Item.create",
101
+ "params": {"tenant_id": tid, "name": "test-item"},
102
+ },
103
+ )
104
+
105
+ assert res.status_code == 200
106
+ data = res.json()["result"]
107
+ assert data["hook_completed"] is True
108
+
109
+ expected_order = [
110
+ "PRE_TX_BEGIN",
111
+ "PRE_HANDLER",
112
+ "POST_HANDLER",
113
+ "PRE_COMMIT",
114
+ "POST_COMMIT",
115
+ "POST_RESPONSE",
116
+ ]
117
+ assert execution_order == expected_order
118
+ await client.aclose()
119
+
120
+
121
+ @pytest.mark.i9n
122
+ @pytest.mark.asyncio
123
+ async def test_hook_parity_crud_vs_rpc(db_mode):
124
+ """Test that hooks execute identically for REST CRUD and RPC calls."""
125
+
126
+ crud_hooks: list[str] = []
127
+ rpc_hooks: list[str] = []
128
+ Base.metadata.clear()
129
+
130
+ class Tenant(Base, GUIDPk):
131
+ __tablename__ = "tenants"
132
+ name = Column(String, nullable=False)
133
+
134
+ class Item(Base, GUIDPk):
135
+ __tablename__ = "items"
136
+ tenant_id = Column(
137
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
138
+ )
139
+ name = Column(String, nullable=False)
140
+
141
+ @hook_ctx(ops="create", phase="PRE_TX_BEGIN")
142
+ async def track_pre_tx(cls, ctx):
143
+ if hasattr(ctx.get("request"), "url") and "/rpc" in str(ctx["request"].url):
144
+ rpc_hooks.append("PRE_TX_BEGIN")
145
+ else:
146
+ crud_hooks.append("PRE_TX_BEGIN")
147
+
148
+ @hook_ctx(ops="create", phase="POST_COMMIT")
149
+ async def track_post_commit(cls, ctx):
150
+ if hasattr(ctx.get("request"), "url") and "/rpc" in str(ctx["request"].url):
151
+ rpc_hooks.append("POST_COMMIT")
152
+ else:
153
+ crud_hooks.append("POST_COMMIT")
154
+
155
+ client, _ = await setup_client(db_mode, Tenant, Item)
156
+
157
+ t = await client.post("/tenant", json={"name": "test-tenant"})
158
+ tid = t.json()["id"]
159
+
160
+ await client.post("/item", json={"tenant_id": tid, "name": "crud-item"})
161
+
162
+ await client.post(
163
+ "/rpc",
164
+ json={
165
+ "method": "Item.create",
166
+ "params": {"tenant_id": tid, "name": "rpc-item"},
167
+ },
168
+ )
169
+
170
+ assert crud_hooks == ["PRE_TX_BEGIN", "POST_COMMIT"]
171
+ assert rpc_hooks == ["PRE_TX_BEGIN", "POST_COMMIT"]
172
+ await client.aclose()
173
+
174
+
175
+ @pytest.mark.i9n
176
+ @pytest.mark.asyncio
177
+ async def test_hook_error_handling(db_mode):
178
+ """Test hook behavior during error conditions."""
179
+
180
+ error_hooks: list[str] = []
181
+ Base.metadata.clear()
182
+
183
+ class Tenant(Base, GUIDPk):
184
+ __tablename__ = "tenants"
185
+ name = Column(String, nullable=False)
186
+
187
+ class Item(Base, GUIDPk):
188
+ __tablename__ = "items"
189
+ tenant_id = Column(
190
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
191
+ )
192
+ name = Column(String, nullable=False)
193
+
194
+ @hook_ctx(ops="*", phase="ON_ERROR")
195
+ async def error_handler(cls, ctx):
196
+ error_hooks.append("ERROR_HANDLED")
197
+ ctx["error_data"] = {"handled": True}
198
+
199
+ @hook_ctx(ops="create", phase="PRE_TX_BEGIN")
200
+ async def failing_hook(cls, ctx):
201
+ raise ValueError("Intentional test error")
202
+
203
+ client, _ = await setup_client(db_mode, Tenant, Item)
204
+
205
+ t = await client.post("/tenant", json={"name": "test-tenant"})
206
+ tid = t.json()["id"]
207
+
208
+ try:
209
+ res = await client.post(
210
+ "/item",
211
+ json={"tenant_id": tid, "name": "error-item"},
212
+ )
213
+ assert res.status_code >= 400
214
+ except Exception:
215
+ pass
216
+
217
+ assert error_hooks == ["ERROR_HANDLED"]
218
+ await client.aclose()
219
+
220
+
221
+ @pytest.mark.i9n
222
+ @pytest.mark.asyncio
223
+ async def test_hook_early_termination_and_cleanup(db_mode):
224
+ """Test early termination when a hook raises and ensure cleanup."""
225
+
226
+ execution_order: list[str] = []
227
+ Base.metadata.clear()
228
+
229
+ class Tenant(Base, GUIDPk):
230
+ __tablename__ = "tenants"
231
+ name = Column(String, nullable=False)
232
+
233
+ class Item(Base, GUIDPk):
234
+ __tablename__ = "items"
235
+ tenant_id = Column(
236
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
237
+ )
238
+ name = Column(String, nullable=False)
239
+
240
+ @hook_ctx(ops="create", phase="PRE_TX_BEGIN")
241
+ async def pre_tx_begin(cls, ctx):
242
+ execution_order.append("PRE_TX_BEGIN")
243
+
244
+ @hook_ctx(ops="create", phase="PRE_HANDLER")
245
+ async def pre_handler(cls, ctx):
246
+ execution_order.append("PRE_HANDLER")
247
+
248
+ @hook_ctx(ops="create", phase="POST_HANDLER")
249
+ async def post_handler(cls, ctx):
250
+ execution_order.append("POST_HANDLER")
251
+
252
+ @hook_ctx(ops="create", phase="PRE_COMMIT")
253
+ async def pre_commit(cls, ctx):
254
+ execution_order.append("PRE_COMMIT")
255
+ raise RuntimeError("boom")
256
+
257
+ @hook_ctx(ops="create", phase="POST_COMMIT")
258
+ async def post_commit(cls, ctx):
259
+ execution_order.append("POST_COMMIT")
260
+
261
+ @hook_ctx(ops="create", phase="POST_RESPONSE")
262
+ async def post_response(cls, ctx):
263
+ execution_order.append("POST_RESPONSE")
264
+
265
+ client, _ = await setup_client(db_mode, Tenant, Item)
266
+
267
+ t = await client.post("/tenant", json={"name": "test-tenant"})
268
+ tid = t.json()["id"]
269
+
270
+ before = await client.get("/item")
271
+ assert before.json() == []
272
+
273
+ try:
274
+ res = await client.post(
275
+ "/item",
276
+ json={"tenant_id": tid, "name": "fail-item"},
277
+ )
278
+ assert res.status_code >= 400
279
+ except RuntimeError:
280
+ pass
281
+
282
+ after = await client.get("/item")
283
+ assert after.json() == []
284
+
285
+ assert execution_order == [
286
+ "PRE_TX_BEGIN",
287
+ "PRE_HANDLER",
288
+ "POST_HANDLER",
289
+ "PRE_COMMIT",
290
+ ]
291
+ await client.aclose()
292
+
293
+
294
+ @pytest.mark.i9n
295
+ @pytest.mark.asyncio
296
+ async def test_hook_context_modification(db_mode):
297
+ """Test that hooks can modify context and affect subsequent hooks."""
298
+
299
+ hook_executions: list[str] = []
300
+ Base.metadata.clear()
301
+
302
+ class Tenant(Base, GUIDPk):
303
+ __tablename__ = "tenants"
304
+ name = Column(String, nullable=False)
305
+
306
+ class Item(Base, GUIDPk):
307
+ __tablename__ = "items"
308
+ tenant_id = Column(
309
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
310
+ )
311
+ name = Column(String, nullable=False)
312
+
313
+ @hook_ctx(ops="create", phase="PRE_TX_BEGIN")
314
+ async def modify_params(cls, ctx):
315
+ hook_executions.append("PRE_TX_BEGIN")
316
+ ctx["custom_data"] = {"modified": True}
317
+
318
+ @hook_ctx(ops="create", phase="POST_HANDLER")
319
+ async def verify_modification(cls, ctx):
320
+ hook_executions.append("POST_HANDLER")
321
+ assert ctx["custom_data"]["modified"] is True
322
+ ctx["custom_data"]["verified"] = True
323
+
324
+ @hook_ctx(ops="create", phase="POST_RESPONSE")
325
+ async def enrich_response(cls, ctx):
326
+ hook_executions.append("POST_RESPONSE")
327
+ assert ctx["custom_data"]["verified"] is True
328
+ assert hasattr(ctx["response"].result, "name")
329
+
330
+ client, _ = await setup_client(db_mode, Tenant, Item)
331
+
332
+ t = await client.post("/tenant", json={"name": "test-tenant"})
333
+ tid = t.json()["id"]
334
+
335
+ res = await client.post("/item", json={"tenant_id": tid, "name": "test-item"})
336
+ assert res.status_code == 201
337
+ data = res.json()
338
+ assert data["name"] == "test-item"
339
+
340
+ assert hook_executions == ["PRE_TX_BEGIN", "POST_HANDLER", "POST_RESPONSE"]
341
+ await client.aclose()
342
+
343
+
344
+ @pytest.mark.i9n
345
+ @pytest.mark.asyncio
346
+ async def test_catch_all_hooks(db_mode):
347
+ """Test that catch-all hooks (no model/op specified) work correctly."""
348
+
349
+ catch_all_executions: list[str] = []
350
+ Base.metadata.clear()
351
+
352
+ class CatchAllMixin:
353
+ @hook_ctx(ops="*", phase="POST_COMMIT")
354
+ async def catch_all_hook(cls, ctx):
355
+ method = f"{cls.__name__}.{getattr(ctx.get('env'), 'method', 'unknown')}"
356
+ catch_all_executions.append(method)
357
+
358
+ @hook_ctx(ops="*", phase="POST_HANDLER")
359
+ async def post_handler_hook(cls, ctx):
360
+ method = f"{cls.__name__}.{getattr(ctx.get('env'), 'method', 'unknown')}"
361
+ if method.endswith(".delete") and method not in catch_all_executions:
362
+ catch_all_executions.append(method)
363
+
364
+ class Tenant(CatchAllMixin, Base, GUIDPk):
365
+ __tablename__ = "tenants"
366
+ name = Column(String, nullable=False)
367
+
368
+ class Item(CatchAllMixin, Base, GUIDPk):
369
+ __tablename__ = "items"
370
+ tenant_id = Column(
371
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
372
+ )
373
+ name = Column(String, nullable=False)
374
+
375
+ client, _ = await setup_client(db_mode, Tenant, Item)
376
+
377
+ t = await client.post("/tenant", json={"name": "test-tenant"})
378
+ tid = t.json()["id"]
379
+
380
+ await client.post("/item", json={"tenant_id": tid, "name": "test-item"})
381
+
382
+ items = await client.get("/item")
383
+ item_id = items.json()[0]["id"]
384
+ await client.get(f"/item/{item_id}")
385
+
386
+ update_res = await client.patch(
387
+ f"/item/{item_id}", json={"tenant_id": tid, "name": "updated-item"}
388
+ )
389
+ update_succeeded = update_res.status_code < 400
390
+
391
+ delete_res = await client.delete(f"/item/{item_id}")
392
+ delete_succeeded = delete_res.status_code < 400
393
+
394
+ expected_methods = [
395
+ "Tenant.create",
396
+ "Item.create",
397
+ "Item.list",
398
+ "Item.read",
399
+ ]
400
+ if update_succeeded:
401
+ expected_methods.append("Item.update")
402
+ if delete_succeeded:
403
+ expected_methods.append("Item.delete")
404
+
405
+ unique_methods = list(dict.fromkeys(catch_all_executions))
406
+ assert unique_methods == expected_methods
407
+ await client.aclose()
408
+
409
+
410
+ @pytest.mark.i9n
411
+ @pytest.mark.asyncio
412
+ async def test_multiple_hooks_same_phase(db_mode):
413
+ """Test that multiple hooks for the same phase execute correctly."""
414
+
415
+ executions: list[str] = []
416
+ Base.metadata.clear()
417
+
418
+ class Tenant(Base, GUIDPk):
419
+ __tablename__ = "tenants"
420
+ name = Column(String, nullable=False)
421
+
422
+ class Item(Base, GUIDPk):
423
+ __tablename__ = "items"
424
+ tenant_id = Column(
425
+ PgUUID(as_uuid=True), ForeignKey("tenants.id"), nullable=False
426
+ )
427
+ name = Column(String, nullable=False)
428
+
429
+ @hook_ctx(ops="create", phase="POST_COMMIT")
430
+ async def first_hook(cls, ctx):
431
+ executions.append("first")
432
+
433
+ @hook_ctx(ops="create", phase="POST_COMMIT")
434
+ async def second_hook(cls, ctx):
435
+ executions.append("second")
436
+
437
+ @hook_ctx(ops="create", phase="POST_COMMIT")
438
+ async def third_hook(cls, ctx):
439
+ executions.append("third")
440
+
441
+ client, _ = await setup_client(db_mode, Tenant, Item)
442
+
443
+ t = await client.post("/tenant", json={"name": "test-tenant"})
444
+ tid = t.json()["id"]
445
+
446
+ await client.post("/item", json={"tenant_id": tid, "name": "test-item"})
447
+
448
+ assert len(executions) == 3
449
+ assert "first" in executions
450
+ assert "second" in executions
451
+ assert "third" in executions
452
+ await client.aclose()