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,360 @@
1
+ """
2
+ Error Mappings and Parity Tests for Tigrbl v3
3
+
4
+ Tests error mappings between RPC and HTTP, and verifies parity between error responses.
5
+ """
6
+
7
+ import pytest
8
+ from tigrbl.types import HTTPException
9
+ from tigrbl.runtime.errors import (
10
+ ERROR_MESSAGES,
11
+ HTTP_ERROR_MESSAGES,
12
+ _HTTP_TO_RPC,
13
+ _RPC_TO_HTTP,
14
+ http_exc_to_rpc,
15
+ rpc_error_to_http,
16
+ create_standardized_error_from_status,
17
+ )
18
+
19
+
20
+ @pytest.mark.i9n
21
+ @pytest.mark.asyncio
22
+ async def test_http_to_rpc_error_mapping():
23
+ """Test that HTTP status codes map correctly to RPC error codes."""
24
+ # Test known mappings from _HTTP_TO_RPC
25
+ test_cases = [
26
+ (400, -32602), # Bad Request -> Invalid params
27
+ (401, -32001), # Unauthorized -> Authentication required
28
+ (403, -32002), # Forbidden -> Insufficient permissions
29
+ (404, -32003), # Not Found -> Resource not found
30
+ (409, -32004), # Conflict -> Resource conflict
31
+ (422, -32602), # Unprocessable Entity -> Invalid params
32
+ (500, -32603), # Internal Server Error -> Internal error
33
+ ]
34
+
35
+ for http_code, expected_rpc_code in test_cases:
36
+ assert _HTTP_TO_RPC[http_code] == expected_rpc_code
37
+
38
+
39
+ @pytest.mark.i9n
40
+ @pytest.mark.asyncio
41
+ async def test_rpc_to_http_error_mapping():
42
+ """Test that RPC error codes map correctly to HTTP status codes."""
43
+ # Test known mappings from _RPC_TO_HTTP
44
+ test_cases = [
45
+ (-32700, 400), # Parse error -> Bad Request
46
+ (-32600, 400), # Invalid Request -> Bad Request
47
+ (-32601, 404), # Method not found -> Not Found
48
+ (-32602, 400), # Invalid params -> Bad Request
49
+ (-32603, 500), # Internal error -> Internal Server Error
50
+ (-32001, 401), # Authentication required -> Unauthorized
51
+ (-32002, 403), # Insufficient permissions -> Forbidden
52
+ (-32003, 404), # Resource not found -> Not Found
53
+ (-32004, 409), # Resource conflict -> Conflict
54
+ ]
55
+
56
+ for rpc_code, expected_http_code in test_cases:
57
+ assert _RPC_TO_HTTP[rpc_code] == expected_http_code
58
+
59
+
60
+ @pytest.mark.i9n
61
+ @pytest.mark.asyncio
62
+ async def test_error_message_standardization():
63
+ """Test that error messages are standardized and consistent."""
64
+ # Test that ERROR_MESSAGES contains expected keys
65
+ expected_rpc_codes = [
66
+ -32700,
67
+ -32600,
68
+ -32601,
69
+ -32602,
70
+ -32603,
71
+ -32001,
72
+ -32002,
73
+ -32003,
74
+ -32004,
75
+ ]
76
+
77
+ for code in expected_rpc_codes:
78
+ assert code in ERROR_MESSAGES
79
+ assert isinstance(ERROR_MESSAGES[code], str)
80
+ assert len(ERROR_MESSAGES[code]) > 0
81
+
82
+ # Test that HTTP_ERROR_MESSAGES contains expected keys
83
+ expected_http_codes = [400, 401, 403, 404, 409, 422, 500]
84
+
85
+ for code in expected_http_codes:
86
+ assert code in HTTP_ERROR_MESSAGES
87
+ assert isinstance(HTTP_ERROR_MESSAGES[code], str)
88
+ assert len(HTTP_ERROR_MESSAGES[code]) > 0
89
+
90
+
91
+ @pytest.mark.i9n
92
+ @pytest.mark.asyncio
93
+ async def test_http_exc_to_rpc_conversion():
94
+ """Test conversion from HTTP exceptions to RPC errors."""
95
+ # Test with standard HTTP exception
96
+ http_exc = HTTPException(status_code=404, detail="Resource not found")
97
+ rpc_code, rpc_message, data = http_exc_to_rpc(http_exc)
98
+
99
+ assert rpc_code == -32003 # Resource not found
100
+ assert rpc_message == "Resource not found"
101
+ assert data is None
102
+
103
+ # Test with HTTP exception that has custom message
104
+ http_exc = HTTPException(status_code=400, detail="Custom bad request message")
105
+ rpc_code, rpc_message, data = http_exc_to_rpc(http_exc)
106
+
107
+ assert rpc_code == -32602 # Invalid params
108
+ assert rpc_message == "Custom bad request message"
109
+ assert data is None
110
+
111
+ # Test with unmapped HTTP status code (should default to internal error)
112
+ http_exc = HTTPException(status_code=418, detail="I'm a teapot")
113
+ rpc_code, rpc_message, data = http_exc_to_rpc(http_exc)
114
+
115
+ assert rpc_code == -32603 # Internal error
116
+ assert rpc_message == "I'm a teapot"
117
+ assert data is None
118
+
119
+ # Detail provided as structured data should be forwarded via data
120
+ http_exc = HTTPException(
121
+ status_code=422,
122
+ detail=[{"loc": ["body", "name"], "msg": "Field required", "type": "missing"}],
123
+ )
124
+ rpc_code, rpc_message, data = http_exc_to_rpc(http_exc)
125
+ assert rpc_code == -32602
126
+ assert isinstance(data, list)
127
+ assert data[0]["loc"] == ["body", "name"]
128
+
129
+
130
+ @pytest.mark.i9n
131
+ @pytest.mark.asyncio
132
+ async def test_rpc_error_to_http_conversion():
133
+ """Test conversion from RPC errors to HTTP exceptions."""
134
+ # Test with standard RPC error
135
+ http_exc = rpc_error_to_http(-32003, "Resource not found")
136
+
137
+ assert http_exc.status_code == 404
138
+ assert http_exc.detail == "Resource not found"
139
+
140
+ # Test with RPC error without custom message (should use default)
141
+ http_exc = rpc_error_to_http(-32001)
142
+
143
+ assert http_exc.status_code == 401
144
+ assert http_exc.detail == HTTP_ERROR_MESSAGES[401]
145
+
146
+ # Test with unmapped RPC error code (should default to 500)
147
+ http_exc = rpc_error_to_http(-99999, "Unknown error")
148
+
149
+ assert http_exc.status_code == 500
150
+ assert http_exc.detail == "Unknown error"
151
+
152
+
153
+ @pytest.mark.i9n
154
+ @pytest.mark.asyncio
155
+ async def test_create_standardized_error():
156
+ """Test creation of standardized errors."""
157
+ # Test creating error from HTTP status
158
+ http_exc, rpc_code, rpc_message = create_standardized_error_from_status(
159
+ 404, "Custom not found"
160
+ )
161
+
162
+ assert http_exc.status_code == 404
163
+ assert http_exc.detail == "Custom not found"
164
+ assert rpc_code == -32003
165
+ assert rpc_message == "Custom not found"
166
+
167
+ # Test creating error with explicit RPC code
168
+ http_exc, rpc_code, rpc_message = create_standardized_error_from_status(
169
+ 400, "Bad request", rpc_code=-32602
170
+ )
171
+
172
+ assert http_exc.status_code == 400
173
+ assert http_exc.detail == "Bad request"
174
+ assert rpc_code == -32602
175
+ assert rpc_message == "Bad request"
176
+
177
+ # Test creating error with default message
178
+ http_exc, rpc_code, rpc_message = create_standardized_error_from_status(401)
179
+
180
+ assert http_exc.status_code == 401
181
+ assert http_exc.detail == HTTP_ERROR_MESSAGES[401]
182
+ assert rpc_code == -32001
183
+ assert rpc_message == ERROR_MESSAGES[-32001]
184
+
185
+
186
+ @pytest.mark.i9n
187
+ @pytest.mark.asyncio
188
+ async def test_error_parity_crud_vs_rpc(api_client):
189
+ """Test that CRUD and RPC operations return equivalent errors."""
190
+ client, api, _ = api_client
191
+
192
+ # Test 404 error parity
193
+ t = await client.post("/tenant", json={"name": "ghost"})
194
+ tid = t.json()["id"]
195
+ # Try to read non-existent item via REST
196
+ rest_response = await client.get(
197
+ f"/tenant/{tid}/item/00000000-0000-0000-0000-000000000000"
198
+ )
199
+ assert rest_response.status_code == 404
200
+ rest_error = rest_response.json()
201
+
202
+ # Try to read non-existent item via RPC
203
+ rpc_response = await client.post(
204
+ "/rpc",
205
+ json={
206
+ "method": "Item.read",
207
+ "params": {
208
+ "tenant_id": tid,
209
+ "id": "00000000-0000-0000-0000-000000000000",
210
+ },
211
+ },
212
+ )
213
+ assert rpc_response.status_code == 200
214
+ rpc_data = rpc_response.json()
215
+ rpc_error = rpc_data["error"]
216
+
217
+ # Both should indicate the same type of error
218
+ assert rpc_error["code"] == -32003 # Resource not found
219
+ # The messages should be equivalent in meaning
220
+ assert "not found" in rest_error["detail"].lower()
221
+ assert "not found" in rpc_error["message"].lower()
222
+
223
+
224
+ @pytest.mark.i9n
225
+ @pytest.mark.asyncio
226
+ async def test_error_parity_validation_errors(api_client):
227
+ """Test that validation errors are consistent between CRUD and RPC."""
228
+ client, api, _ = api_client
229
+
230
+ # Test validation error - missing required field
231
+ # Try via REST
232
+ rest_response = await client.post("/tenant", json={}) # Missing name
233
+ assert rest_response.status_code == 422
234
+ rest_error = rest_response.json()
235
+
236
+ # Try via RPC
237
+ rpc_response = await client.post(
238
+ "/rpc",
239
+ json={"method": "Tenant.create", "params": {}}, # Missing name
240
+ )
241
+ assert rpc_response.status_code == 200
242
+ rpc_data = rpc_response.json()
243
+ rpc_error = rpc_data["error"]
244
+
245
+ # Both should indicate a validation-related problem
246
+ assert rpc_error["code"] in (-32602, -32004)
247
+ # Both should mention the validation issue
248
+ assert "name" in str(rest_error).lower()
249
+ assert "name" in rpc_error["message"].lower()
250
+ rpc_error_data = rpc_error.get("data") or []
251
+ if rpc_error_data:
252
+ assert any("name" in str(err.get("loc", "")).lower() for err in rpc_error_data)
253
+
254
+
255
+ @pytest.mark.i9n
256
+ @pytest.mark.asyncio
257
+ async def test_error_mapping_bidirectional_consistency():
258
+ """Test that error mappings are bidirectionally consistent."""
259
+ # For each HTTP->RPC mapping, verify the reverse RPC->HTTP mapping exists
260
+ for http_code, rpc_code in _HTTP_TO_RPC.items():
261
+ assert rpc_code in _RPC_TO_HTTP
262
+ # The reverse mapping should map back to a reasonable HTTP code
263
+ reverse_http_code = _RPC_TO_HTTP[rpc_code]
264
+ # It doesn't have to be exactly the same (e.g., 422 and 400 both map to -32602)
265
+ # But it should be in the same error class
266
+ assert reverse_http_code // 100 == http_code // 100 or (
267
+ http_code in [400, 422] and reverse_http_code in [400, 422]
268
+ )
269
+
270
+
271
+ @pytest.mark.i9n
272
+ @pytest.mark.asyncio
273
+ async def test_error_response_structure(api_client):
274
+ """Test that error responses have consistent structure."""
275
+ client, api, _ = api_client
276
+
277
+ # Test REST error structure
278
+ rest_response = await client.get("/item/invalid-uuid")
279
+ rest_error = rest_response.json()
280
+
281
+ # REST errors should have detail field
282
+ assert "detail" in rest_error
283
+
284
+ # Test RPC error structure
285
+ rpc_response = await client.post(
286
+ "/rpc", json={"method": "Item.read", "params": {"id": "invalid-uuid"}}
287
+ )
288
+ rpc_data = rpc_response.json()
289
+ rpc_error = rpc_data["error"]
290
+
291
+ # RPC errors should have code and message
292
+ assert "code" in rpc_error
293
+ assert "message" in rpc_error
294
+ assert isinstance(rpc_error["code"], int)
295
+ assert isinstance(rpc_error["message"], str)
296
+
297
+
298
+ @pytest.mark.i9n
299
+ @pytest.mark.asyncio
300
+ async def test_custom_error_messages_preserved():
301
+ """Test that custom error messages are preserved through conversions."""
302
+ custom_message = "This is a custom error message for testing"
303
+
304
+ # Test HTTP to RPC conversion preserves message
305
+ http_exc = HTTPException(status_code=404, detail=custom_message)
306
+ rpc_code, rpc_message, data = http_exc_to_rpc(http_exc)
307
+ assert rpc_message == custom_message
308
+ assert data is None
309
+
310
+ # Test RPC to HTTP conversion preserves message
311
+ http_exc = rpc_error_to_http(-32003, custom_message)
312
+ assert http_exc.detail == custom_message
313
+
314
+ # Test standardized error creation preserves message
315
+ http_exc, rpc_code, rpc_message = create_standardized_error_from_status(
316
+ 404, custom_message
317
+ )
318
+ assert http_exc.detail == custom_message
319
+ assert rpc_message == custom_message
320
+
321
+
322
+ @pytest.mark.i9n
323
+ @pytest.mark.asyncio
324
+ async def test_error_mapping_completeness():
325
+ """Test that error mappings cover all expected scenarios."""
326
+ # Common HTTP error codes should be mapped
327
+ common_http_codes = [400, 401, 403, 404, 409, 422, 500, 503]
328
+
329
+ for code in common_http_codes:
330
+ if code in _HTTP_TO_RPC:
331
+ # If mapped, should have corresponding RPC code
332
+ rpc_code = _HTTP_TO_RPC[code]
333
+ assert rpc_code in _RPC_TO_HTTP
334
+ assert rpc_code in ERROR_MESSAGES
335
+
336
+ # Should have HTTP error message
337
+ if code in HTTP_ERROR_MESSAGES:
338
+ assert isinstance(HTTP_ERROR_MESSAGES[code], str)
339
+ assert len(HTTP_ERROR_MESSAGES[code]) > 0
340
+
341
+ # Common RPC error codes should be mapped
342
+ common_rpc_codes = [
343
+ -32700,
344
+ -32600,
345
+ -32601,
346
+ -32602,
347
+ -32603,
348
+ -32001,
349
+ -32002,
350
+ -32003,
351
+ -32004,
352
+ ]
353
+
354
+ for code in common_rpc_codes:
355
+ assert code in _RPC_TO_HTTP
356
+ assert code in ERROR_MESSAGES
357
+
358
+ # Should map to valid HTTP code
359
+ http_code = _RPC_TO_HTTP[code]
360
+ assert 400 <= http_code <= 599
@@ -0,0 +1,117 @@
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+ import pytest_asyncio
5
+ from tigrbl.types import App
6
+ from httpx import ASGITransport, AsyncClient
7
+ from tigrbl.engine import resolver as _resolver
8
+ from tigrbl.engine.shortcuts import mem
9
+ from sqlalchemy.orm import sessionmaker
10
+
11
+ from tigrbl import TigrblApp
12
+ from tigrbl.orm.tables import Base
13
+ from tigrbl.orm.mixins import GUIDPk
14
+ from tigrbl.specs import acol, F, IO, S
15
+ from tigrbl.types import String
16
+ from tigrbl.runtime.atoms.schema import collect_in
17
+
18
+
19
+ @pytest_asyncio.fixture
20
+ async def fs_app():
21
+ Base.metadata.clear()
22
+ cfg = mem(async_=False)
23
+ _resolver.set_default(cfg)
24
+ prov = _resolver.resolve_provider()
25
+ engine, maker = prov.ensure()
26
+ SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
27
+
28
+ class FSItem(Base, GUIDPk):
29
+ __tablename__ = "fs_items"
30
+ name = acol(
31
+ storage=S(type_=String, nullable=False),
32
+ field=F(
33
+ constraints={"max_length": 5},
34
+ required_in=("create",),
35
+ allow_null_in=("update",),
36
+ ),
37
+ io=IO(in_verbs=("create", "update"), out_verbs=("read",)),
38
+ )
39
+
40
+ Base.metadata.create_all(engine)
41
+ app = App()
42
+ api = TigrblApp(engine=cfg)
43
+ api.include_model(FSItem)
44
+ api.initialize()
45
+ app.include_router(api.router)
46
+ transport = ASGITransport(app=app)
47
+ client = AsyncClient(transport=transport, base_url="http://test")
48
+ try:
49
+ yield client, api, SessionLocal, FSItem
50
+ finally:
51
+ await client.aclose()
52
+ engine.dispose()
53
+ _resolver.set_default(None)
54
+
55
+
56
+ @pytest.mark.i9n
57
+ @pytest.mark.asyncio
58
+ async def test_field_spec_openapi(fs_app):
59
+ client, _, _, _ = fs_app
60
+ spec = (await client.get("/openapi.json")).json()
61
+ schema = spec["components"]["schemas"]["FSItemCreateRequest"]
62
+ assert "name" in schema["required"]
63
+ assert schema["properties"]["name"]["type"] == "string"
64
+
65
+
66
+ @pytest.mark.i9n
67
+ @pytest.mark.asyncio
68
+ async def test_field_spec_column_length(fs_app):
69
+ _, _, _, FSItem = fs_app
70
+ assert FSItem.__table__.c.name.type.length == 5
71
+
72
+
73
+ @pytest.mark.i9n
74
+ @pytest.mark.asyncio
75
+ async def test_field_spec_rest_required(fs_app):
76
+ client, _, _, _ = fs_app
77
+ resp = await client.post("/fsitem", json={})
78
+ assert resp.status_code == 422
79
+
80
+
81
+ @pytest.mark.i9n
82
+ @pytest.mark.asyncio
83
+ async def test_field_spec_allow_null_update(fs_app):
84
+ client, _, SessionLocal, FSItem = fs_app
85
+ create = await client.post("/fsitem", json={"name": "ok"})
86
+ item_id = create.json()["id"]
87
+ upd = await client.patch(f"/fsitem/{item_id}", json={"name": None})
88
+ assert upd.status_code == 422
89
+
90
+
91
+ @pytest.mark.i9n
92
+ @pytest.mark.asyncio
93
+ async def test_field_spec_rpc_required(fs_app):
94
+ _, api, SessionLocal, FSItem = fs_app
95
+ with SessionLocal() as session:
96
+ with pytest.raises(Exception):
97
+ await api.rpc_call(FSItem, "create", payload={}, db=session)
98
+
99
+
100
+ @pytest.mark.i9n
101
+ @pytest.mark.asyncio
102
+ async def test_field_spec_core_crud_create(fs_app):
103
+ _, api, SessionLocal, FSItem = fs_app
104
+ with SessionLocal() as session:
105
+ obj = await api.core.FSItem.create({"name": "hi"}, db=session)
106
+ assert obj["name"] == "hi"
107
+
108
+
109
+ @pytest.mark.i9n
110
+ @pytest.mark.asyncio
111
+ async def test_field_spec_collect_in_atom(fs_app):
112
+ _, _, _, FSItem = fs_app
113
+ specs = FSItem.__tigrbl_cols__
114
+ ctx = SimpleNamespace(specs=specs, op="create", temp={})
115
+ collect_in.run(None, ctx)
116
+ schema = ctx.temp["schema_in"]
117
+ assert schema["by_field"]["name"]["required"] is True
@@ -0,0 +1,71 @@
1
+ import httpx
2
+ import pytest
3
+ import pytest_asyncio
4
+
5
+ from tigrbl import TigrblApp
6
+ from tigrbl.orm.mixins import GUIDPk
7
+ from tigrbl.orm.tables._base import Base
8
+ from tigrbl.specs import F, S, IO, acol
9
+ from tigrbl.types import App, Mapped, String
10
+
11
+ from .uvicorn_utils import run_uvicorn_in_task, stop_uvicorn_server
12
+
13
+
14
+ class Item(Base, GUIDPk):
15
+ __tablename__ = "items_hdr"
16
+ __resource__ = "item"
17
+
18
+ name: Mapped[str] = acol(
19
+ storage=S(String, nullable=False),
20
+ field=F(py_type=str),
21
+ io=IO(in_verbs=("create",), out_verbs=("create", "read")),
22
+ )
23
+ worker_key: Mapped[str] = acol(
24
+ storage=S(String, nullable=False),
25
+ field=F(py_type=str),
26
+ io=IO(
27
+ in_verbs=("create",),
28
+ out_verbs=("create", "read"),
29
+ header_in="X-Worker-Key",
30
+ header_required_in=True,
31
+ ),
32
+ )
33
+ __tigrbl_cols__ = {
34
+ "id": GUIDPk.id,
35
+ "name": name,
36
+ "worker_key": worker_key,
37
+ }
38
+
39
+
40
+ @pytest_asyncio.fixture()
41
+ async def running_app(sync_db_session):
42
+ engine, get_sync_db = sync_db_session
43
+
44
+ app = App()
45
+ api = TigrblApp(get_db=get_sync_db)
46
+ api.include_models([Item])
47
+ await api.initialize()
48
+ app.include_router(api.router)
49
+
50
+ base_url, server, task = await run_uvicorn_in_task(app)
51
+ try:
52
+ yield base_url
53
+ finally:
54
+ await stop_uvicorn_server(server, task)
55
+
56
+
57
+ @pytest.mark.i9n
58
+ @pytest.mark.asyncio
59
+ @pytest.mark.parametrize(
60
+ "headers, status",
61
+ [({}, 422), ({"X-Worker-Key": "alpha"}, 201)],
62
+ )
63
+ async def test_header_in_out(running_app, headers, status):
64
+ base_url = running_app
65
+ payload = {"name": "foo", "worker_key": "body"}
66
+ async with httpx.AsyncClient() as client:
67
+ resp = await client.post(f"{base_url}/item", json=payload, headers=headers)
68
+ assert resp.status_code == status
69
+ if status == 201:
70
+ body = resp.json()
71
+ assert body["worker_key"] == "alpha"