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,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"
|