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,20 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.mark.i9n
5
+ @pytest.mark.asyncio
6
+ async def test_list_filters_optional(api_client):
7
+ client, _, _ = api_client
8
+
9
+ spec = (await client.get("/openapi.json")).json()
10
+ params = spec["paths"]["/tenant"]["get"].get("parameters", [])
11
+ name_param = next(p for p in params if p["name"] == "name")
12
+ assert name_param["required"] is False
13
+
14
+ r = await client.get("/tenant")
15
+ assert r.status_code == 200
16
+ assert r.json() == []
17
+
18
+ r2 = await client.get("/tenant", params={"name": "foo"})
19
+ assert r2.status_code == 200
20
+ assert r2.json() == []
@@ -0,0 +1,534 @@
1
+ """
2
+ Mixins Tests for Tigrbl v3
3
+
4
+ Tests all mixins and their expected behavior using individual DummyModel instances.
5
+ """
6
+
7
+ import pytest
8
+ from datetime import datetime, timedelta, timezone
9
+ from tigrbl.types import String, uuid4
10
+ from tigrbl.column.shortcuts import acol, F, IO, S
11
+
12
+ from tigrbl import Base
13
+ from tigrbl.orm.mixins import (
14
+ ActiveToggle,
15
+ AsyncCapable,
16
+ Audited,
17
+ BulkCapable,
18
+ Created,
19
+ ExtRef,
20
+ GUIDPk,
21
+ LastUsed,
22
+ MetaJSON,
23
+ Monetary,
24
+ RelationEdge,
25
+ Replaceable,
26
+ Slugged,
27
+ SoftDelete,
28
+ StatusColumn,
29
+ Streamable,
30
+ Timestamped,
31
+ ValidityWindow,
32
+ Versioned,
33
+ tzutcnow,
34
+ tzutcnow_plus_day,
35
+ )
36
+ from tigrbl.schema import _build_schema
37
+ from tigrbl.engine import resolver as _resolver
38
+
39
+
40
+ NAME_FIELD = acol(
41
+ storage=S(type_=String, nullable=False),
42
+ field=F(py_type=str),
43
+ io=IO(
44
+ in_verbs=("create", "update", "replace"),
45
+ out_verbs=("read", "list"),
46
+ mutable_verbs=("create", "update", "replace"),
47
+ filter_ops=("eq",),
48
+ ),
49
+ )
50
+
51
+
52
+ class DummyModelTimestamped(Base, GUIDPk, Timestamped):
53
+ """Test model for Timestamped mixin."""
54
+
55
+ __tablename__ = "dummy_timestamped"
56
+ name = NAME_FIELD
57
+
58
+
59
+ class DummyModelCreated(Base, GUIDPk, Created):
60
+ """Test model for Created mixin."""
61
+
62
+ __tablename__ = "dummy_created"
63
+ name = NAME_FIELD
64
+
65
+
66
+ class DummyModelLastUsed(Base, GUIDPk, LastUsed):
67
+ """Test model for LastUsed mixin."""
68
+
69
+ __tablename__ = "dummy_last_used"
70
+ name = NAME_FIELD
71
+
72
+
73
+ class DummyModelActiveToggle(Base, GUIDPk, ActiveToggle):
74
+ """Test model for ActiveToggle mixin."""
75
+
76
+ __tablename__ = "dummy_active_toggle"
77
+ name = NAME_FIELD
78
+
79
+
80
+ class DummyModelSoftDelete(Base, GUIDPk, SoftDelete):
81
+ """Test model for SoftDelete mixin."""
82
+
83
+ __tablename__ = "dummy_soft_delete"
84
+ name = NAME_FIELD
85
+
86
+
87
+ class DummyModelVersioned(Base, GUIDPk, Versioned):
88
+ """Test model for Versioned mixin."""
89
+
90
+ __tablename__ = "dummy_versioned"
91
+ name = NAME_FIELD
92
+
93
+
94
+ class DummyModelBulkCapable(Base, GUIDPk, BulkCapable):
95
+ """Test model for BulkCapable mixin."""
96
+
97
+ __tablename__ = "dummy_bulk_capable"
98
+ name = NAME_FIELD
99
+
100
+
101
+ class DummyModelReplaceable(Base, GUIDPk, Replaceable):
102
+ """Test model for Replaceable mixin."""
103
+
104
+ __tablename__ = "dummy_replaceable"
105
+ name = NAME_FIELD
106
+
107
+
108
+ class DummyModelAsyncCapable(Base, GUIDPk, AsyncCapable):
109
+ """Test model for AsyncCapable mixin."""
110
+
111
+ __tablename__ = "dummy_async_capable"
112
+ name = NAME_FIELD
113
+
114
+
115
+ class DummyModelSlugged(Base, GUIDPk, Slugged):
116
+ """Test model for Slugged mixin."""
117
+
118
+ __tablename__ = "dummy_slugged"
119
+ name = NAME_FIELD
120
+
121
+
122
+ class DummyModelStatusColumn(Base, GUIDPk, StatusColumn):
123
+ """Test model for StatusColumn."""
124
+
125
+ __tablename__ = "dummy_status_column"
126
+ name = NAME_FIELD
127
+
128
+
129
+ class DummyModelValidityWindow(Base, GUIDPk, ValidityWindow):
130
+ """Test model for ValidityWindow mixin."""
131
+
132
+ __tablename__ = "dummy_validity_window"
133
+ name = NAME_FIELD
134
+
135
+
136
+ class DummyModelMonetary(Base, GUIDPk, Monetary):
137
+ """Test model for Monetary mixin."""
138
+
139
+ __tablename__ = "dummy_monetary"
140
+ name = NAME_FIELD
141
+
142
+
143
+ class DummyModelExtRef(Base, GUIDPk, ExtRef):
144
+ """Test model for ExtRef mixin."""
145
+
146
+ __tablename__ = "dummy_ext_ref"
147
+ name = NAME_FIELD
148
+
149
+
150
+ class DummyModelMetaJSON(Base, GUIDPk, MetaJSON):
151
+ """Test model for MetaJSON mixin."""
152
+
153
+ __tablename__ = "dummy_meta_json"
154
+ name = NAME_FIELD
155
+
156
+
157
+ @pytest.mark.i9n
158
+ @pytest.mark.asyncio
159
+ async def test_timestamped_mixin(create_test_api):
160
+ """Test that Timestamped mixin adds created_at and updated_at fields."""
161
+ create_test_api(DummyModelTimestamped)
162
+
163
+ # Get schemas
164
+ create_schema = _build_schema(DummyModelTimestamped, verb="create")
165
+ read_schema = _build_schema(DummyModelTimestamped, verb="read")
166
+ update_schema = _build_schema(DummyModelTimestamped, verb="update")
167
+
168
+ # created_at and updated_at are read-only and appear only in read schema
169
+ assert "created_at" in read_schema.model_fields
170
+ assert "updated_at" in read_schema.model_fields
171
+ assert "created_at" not in create_schema.model_fields
172
+ assert "updated_at" not in create_schema.model_fields
173
+ assert "created_at" not in update_schema.model_fields
174
+ assert "updated_at" not in update_schema.model_fields
175
+
176
+ # name should be in all schemas
177
+ assert "name" in create_schema.model_fields
178
+ assert "name" in read_schema.model_fields
179
+ assert "name" in update_schema.model_fields
180
+
181
+
182
+ @pytest.mark.i9n
183
+ @pytest.mark.asyncio
184
+ async def test_created_mixin(create_test_api):
185
+ """Test that Created mixin adds created_at field."""
186
+ create_test_api(DummyModelCreated)
187
+
188
+ # Get schemas
189
+ create_schema = _build_schema(DummyModelCreated, verb="create")
190
+ read_schema = _build_schema(DummyModelCreated, verb="read")
191
+
192
+ # created_at is read-only and only present in read schema
193
+ assert "created_at" in read_schema.model_fields
194
+ assert "created_at" not in create_schema.model_fields
195
+
196
+
197
+ @pytest.mark.i9n
198
+ @pytest.mark.asyncio
199
+ async def test_last_used_mixin(create_test_api):
200
+ """Test that LastUsed mixin adds last_used_at field and touch method."""
201
+ create_test_api(DummyModelLastUsed)
202
+
203
+ # Get schemas
204
+ read_schema = _build_schema(DummyModelLastUsed, verb="read")
205
+
206
+ # last_used_at should be in read schema
207
+ assert "last_used_at" in read_schema.model_fields
208
+
209
+ # Verify the model has touch method
210
+ assert hasattr(DummyModelLastUsed, "touch")
211
+
212
+ # Test touch method functionality
213
+ instance = DummyModelLastUsed(name="test")
214
+ assert instance.last_used_at is None
215
+
216
+ instance.touch()
217
+ assert instance.last_used_at is not None
218
+ assert isinstance(instance.last_used_at, datetime)
219
+
220
+
221
+ @pytest.mark.i9n
222
+ @pytest.mark.asyncio
223
+ async def test_active_toggle_mixin(create_test_api):
224
+ """Test that ActiveToggle mixin adds is_active field."""
225
+ create_test_api(DummyModelActiveToggle)
226
+
227
+ # Get schemas
228
+ create_schema = _build_schema(DummyModelActiveToggle, verb="create")
229
+ read_schema = _build_schema(DummyModelActiveToggle, verb="read")
230
+
231
+ # is_active should be in schemas
232
+ assert "is_active" in create_schema.model_fields
233
+ assert "is_active" in read_schema.model_fields
234
+
235
+ # is_active field should be boolean type (default may be None)
236
+ is_active_field = create_schema.model_fields["is_active"]
237
+ assert is_active_field.annotation is bool
238
+
239
+
240
+ @pytest.mark.i9n
241
+ @pytest.mark.asyncio
242
+ async def test_soft_delete_mixin(create_test_api):
243
+ """Test that SoftDelete mixin adds deleted_at field."""
244
+ create_test_api(DummyModelSoftDelete)
245
+
246
+ # Get schemas
247
+ read_schema = _build_schema(DummyModelSoftDelete, verb="read")
248
+
249
+ # deleted_at should be in read schema
250
+ assert "deleted_at" in read_schema.model_fields
251
+
252
+
253
+ @pytest.mark.i9n
254
+ @pytest.mark.asyncio
255
+ async def test_versioned_mixin(create_test_api):
256
+ """Test that Versioned mixin adds revision and prev_id fields."""
257
+ create_test_api(DummyModelVersioned)
258
+
259
+ # Get schemas
260
+ create_schema = _build_schema(DummyModelVersioned, verb="create")
261
+ read_schema = _build_schema(DummyModelVersioned, verb="read")
262
+
263
+ # revision and prev_id should be in schemas
264
+ assert "revision" in read_schema.model_fields
265
+ assert "prev_id" in read_schema.model_fields
266
+
267
+ # revision should have default value of 1
268
+ revision_field = create_schema.model_fields["revision"]
269
+ assert revision_field.annotation is int
270
+
271
+
272
+ @pytest.mark.i9n
273
+ @pytest.mark.asyncio
274
+ async def test_bulk_capable_mixin(create_test_api):
275
+ """Test that BulkCapable mixin enables bulk operations."""
276
+ api = create_test_api(DummyModelBulkCapable)
277
+
278
+ # Check that bulk routes are available
279
+ routes = [route.path for route in api.router.routes]
280
+
281
+ # Bulk operations now share the base collection path
282
+ expected_path = f"/{DummyModelBulkCapable.__name__.lower()}"
283
+ assert expected_path in routes
284
+
285
+
286
+ @pytest.mark.i9n
287
+ @pytest.mark.asyncio
288
+ async def test_replaceable_mixin(create_test_api):
289
+ """Test that Replaceable mixin enables replacement operations."""
290
+ create_test_api(DummyModelReplaceable)
291
+
292
+ # Get schemas
293
+ create_schema = _build_schema(DummyModelReplaceable, verb="create")
294
+ read_schema = _build_schema(DummyModelReplaceable, verb="read")
295
+
296
+ # Should have basic fields
297
+ assert "name" in create_schema.model_fields
298
+ assert "name" in read_schema.model_fields
299
+
300
+ # Replaceable mixin is a marker mixin - doesn't add fields
301
+ # but enables replacement functionality
302
+ expected_fields = {"id", "name"}
303
+ actual_fields = set(read_schema.model_fields.keys())
304
+ assert expected_fields.issubset(actual_fields)
305
+
306
+
307
+ @pytest.mark.i9n
308
+ @pytest.mark.asyncio
309
+ async def test_async_capable_mixin(create_test_api):
310
+ """Test that AsyncCapable mixin is a marker mixin."""
311
+ create_test_api(DummyModelAsyncCapable)
312
+
313
+ # Get schemas
314
+ read_schema = _build_schema(DummyModelAsyncCapable, verb="read")
315
+
316
+ # AsyncCapable is a marker mixin - doesn't add fields
317
+ expected_fields = {"id", "name"}
318
+ actual_fields = set(read_schema.model_fields.keys())
319
+ assert actual_fields == expected_fields
320
+
321
+
322
+ @pytest.mark.i9n
323
+ @pytest.mark.asyncio
324
+ async def test_slugged_mixin(create_test_api):
325
+ """Test that Slugged mixin adds slug field."""
326
+ create_test_api(DummyModelSlugged)
327
+
328
+ # Get schemas
329
+ create_schema = _build_schema(DummyModelSlugged, verb="create")
330
+ read_schema = _build_schema(DummyModelSlugged, verb="read")
331
+
332
+ # slug should be in schemas
333
+ assert "slug" in create_schema.model_fields
334
+ assert "slug" in read_schema.model_fields
335
+
336
+
337
+ @pytest.mark.i9n
338
+ @pytest.mark.asyncio
339
+ async def test_status_column(create_test_api):
340
+ """Test that StatusColumn adds status field."""
341
+ create_test_api(DummyModelStatusColumn)
342
+
343
+ # Get schemas
344
+ create_schema = _build_schema(DummyModelStatusColumn, verb="create")
345
+ read_schema = _build_schema(DummyModelStatusColumn, verb="read")
346
+
347
+ # status should be in schemas
348
+ assert "status" in create_schema.model_fields
349
+ assert "status" in read_schema.model_fields
350
+
351
+ # status field should be string type
352
+ status_field = create_schema.model_fields["status"]
353
+ assert status_field.annotation is str
354
+
355
+
356
+ @pytest.mark.i9n
357
+ @pytest.mark.asyncio
358
+ async def test_validity_window_mixin(create_test_api):
359
+ """Test that ValidityWindow mixin adds valid_from and valid_until fields."""
360
+ create_test_api(DummyModelValidityWindow)
361
+
362
+ # Get schemas
363
+ create_schema = _build_schema(DummyModelValidityWindow, verb="create")
364
+ read_schema = _build_schema(DummyModelValidityWindow, verb="read")
365
+
366
+ # validity fields should be in schemas
367
+ assert "valid_from" in create_schema.model_fields
368
+ assert "valid_to" in create_schema.model_fields
369
+ assert "valid_from" in read_schema.model_fields
370
+ assert "valid_to" in read_schema.model_fields
371
+
372
+
373
+ @pytest.mark.i9n
374
+ @pytest.mark.asyncio
375
+ async def test_validity_window_default(create_test_api):
376
+ api = create_test_api(DummyModelValidityWindow)
377
+ session, release = _resolver.acquire(api=api)
378
+ try:
379
+ vf_default = tzutcnow()
380
+ vt_default = tzutcnow_plus_day()
381
+ instance = DummyModelValidityWindow(
382
+ id=uuid4(), name="x", valid_from=vf_default, valid_to=vt_default
383
+ )
384
+ session.add(instance)
385
+ session.flush()
386
+ finally:
387
+ release()
388
+ assert vf_default is not None
389
+ assert vt_default is not None
390
+ assert abs((vt_default - vf_default) - timedelta(days=1)) < timedelta(seconds=1)
391
+
392
+
393
+ @pytest.mark.i9n
394
+ @pytest.mark.asyncio
395
+ async def test_tzutcnow():
396
+ """tzutcnow returns an aware UTC datetime close to current time."""
397
+ now = tzutcnow()
398
+ assert now.tzinfo == timezone.utc
399
+ assert abs(now - datetime.now(timezone.utc)) < timedelta(seconds=1)
400
+
401
+
402
+ @pytest.mark.i9n
403
+ @pytest.mark.asyncio
404
+ async def test_tzutcnow_plus_day():
405
+ """tzutcnow_plus_day returns an aware UTC datetime one day ahead."""
406
+ now = tzutcnow()
407
+ future = tzutcnow_plus_day()
408
+ assert future.tzinfo == timezone.utc
409
+ assert future > now
410
+ assert abs((future - now) - timedelta(days=1)) < timedelta(seconds=1)
411
+
412
+
413
+ @pytest.mark.i9n
414
+ @pytest.mark.asyncio
415
+ async def test_monetary_mixin(create_test_api):
416
+ """Test that Monetary mixin adds currency and amount fields."""
417
+ create_test_api(DummyModelMonetary)
418
+
419
+ # Get schemas
420
+ create_schema = _build_schema(DummyModelMonetary, verb="create")
421
+ read_schema = _build_schema(DummyModelMonetary, verb="read")
422
+
423
+ # monetary fields should be in schemas
424
+ assert "currency" in create_schema.model_fields
425
+ assert "amount" in create_schema.model_fields
426
+ assert "currency" in read_schema.model_fields
427
+ assert "amount" in read_schema.model_fields
428
+
429
+ # currency field should be string type
430
+ currency_field = create_schema.model_fields["currency"]
431
+ assert currency_field.annotation is str
432
+
433
+
434
+ @pytest.mark.i9n
435
+ @pytest.mark.asyncio
436
+ async def test_ext_ref_mixin(create_test_api):
437
+ """Test that ExtRef mixin adds external_id field."""
438
+ create_test_api(DummyModelExtRef)
439
+
440
+ # Get schemas
441
+ create_schema = _build_schema(DummyModelExtRef, verb="create")
442
+ read_schema = _build_schema(DummyModelExtRef, verb="read")
443
+
444
+ # external_id should be in schemas
445
+ assert "external_id" in create_schema.model_fields
446
+ assert "external_id" in read_schema.model_fields
447
+
448
+
449
+ @pytest.mark.i9n
450
+ @pytest.mark.asyncio
451
+ @pytest.mark.skip(reason="JSONB type not supported in SQLite test environment")
452
+ async def test_meta_json_mixin(create_test_api):
453
+ """Test that MetaJSON mixin adds meta field."""
454
+ create_test_api(DummyModelMetaJSON)
455
+
456
+ # Get schemas
457
+ create_schema = _build_schema(DummyModelMetaJSON, verb="create")
458
+ read_schema = _build_schema(DummyModelMetaJSON, verb="read")
459
+
460
+ # meta should be in schemas
461
+ assert "meta" in create_schema.model_fields
462
+ assert "meta" in read_schema.model_fields
463
+
464
+ # meta should default to empty dict
465
+ meta_field = create_schema.model_fields["meta"]
466
+ assert meta_field.default == {}
467
+
468
+
469
+ @pytest.mark.i9n
470
+ @pytest.mark.asyncio
471
+ async def test_marker_mixins(create_test_api):
472
+ """Test that marker mixins (Audited, Streamable, etc.) don't add fields."""
473
+
474
+ # Create dummy models for other marker mixins
475
+ class DummyAudited(Base, GUIDPk, Audited):
476
+ __tablename__ = "dummy_audited"
477
+ name = NAME_FIELD
478
+
479
+ class DummyStreamable(Base, GUIDPk, Streamable):
480
+ __tablename__ = "dummy_streamable"
481
+ name = NAME_FIELD
482
+
483
+ class DummyRelationEdge(Base, GUIDPk, RelationEdge):
484
+ __tablename__ = "dummy_relation_edge"
485
+ name = NAME_FIELD
486
+
487
+ marker_models = [DummyAudited, DummyStreamable, DummyRelationEdge]
488
+
489
+ for model in marker_models:
490
+ create_test_api(model)
491
+
492
+ read_schema = _build_schema(model, verb="read")
493
+
494
+ # Should only have id and name fields (no extra fields from marker mixins)
495
+ expected_fields = {"id", "name"}
496
+ actual_fields = set(read_schema.model_fields.keys())
497
+ assert actual_fields == expected_fields
498
+
499
+
500
+ @pytest.mark.i9n
501
+ @pytest.mark.asyncio
502
+ async def test_multiple_mixins_combination(create_test_api):
503
+ """Test that multiple mixins can be combined correctly."""
504
+
505
+ class DummyMultipleMixins(
506
+ Base, GUIDPk, Timestamped, ActiveToggle, Slugged, StatusColumn
507
+ ):
508
+ __tablename__ = "dummy_multiple_mixins"
509
+ name = NAME_FIELD
510
+
511
+ create_test_api(DummyMultipleMixins)
512
+
513
+ # Get schemas
514
+ create_schema = _build_schema(DummyMultipleMixins, verb="create")
515
+ read_schema = _build_schema(DummyMultipleMixins, verb="read")
516
+
517
+ # Should have fields from all mixins
518
+ # From ActiveToggle
519
+ assert "is_active" in create_schema.model_fields
520
+ assert "is_active" in read_schema.model_fields
521
+
522
+ # From Slugged
523
+ assert "slug" in create_schema.model_fields
524
+ assert "slug" in read_schema.model_fields
525
+
526
+ # From StatusColumn
527
+ assert "status" in create_schema.model_fields
528
+ assert "status" in read_schema.model_fields
529
+
530
+ # From Timestamped - read-only fields should only appear in read schema
531
+ assert "created_at" not in create_schema.model_fields
532
+ assert "updated_at" not in create_schema.model_fields
533
+ assert "created_at" in read_schema.model_fields
534
+ assert "updated_at" in read_schema.model_fields
@@ -0,0 +1,34 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.mark.i9n
5
+ @pytest.mark.asyncio
6
+ async def test_nested_path_schema_and_rpc(api_client):
7
+ client, _, Item = api_client
8
+
9
+ # Create a tenant
10
+ tenant_res = await client.post("/tenant", json={"name": "Acme"})
11
+ tenant_res.raise_for_status()
12
+ tenant_id = tenant_res.json()["id"]
13
+
14
+ # Schema should mark parent identifiers optional
15
+ create_model = Item.schemas.create.in_
16
+ fields = getattr(create_model, "model_fields", None)
17
+ if fields is None:
18
+ fields = getattr(create_model, "__fields__", {})
19
+ assert "tenant_id" not in fields
20
+
21
+ # REST call should inject path params
22
+ rest_payload = [create_model(name="rest-item").model_dump(exclude_none=True)]
23
+ rest_res = await client.post(f"/tenant/{tenant_id}/item", json=rest_payload)
24
+ rest_res.raise_for_status()
25
+ rest_item = rest_res.json()[0]
26
+ assert rest_item["tenant_id"] == tenant_id
27
+
28
+ # RPC call should succeed when tenant_id is provided explicitly
29
+ rpc_payload = {
30
+ "method": "Item.create",
31
+ "params": {"tenant_id": tenant_id, "name": "rpc-item"},
32
+ }
33
+ rpc_res = await client.post("/rpc", json=rpc_payload)
34
+ rpc_res.raise_for_status()
@@ -0,0 +1,118 @@
1
+ import pytest
2
+ import pytest_asyncio
3
+ from tigrbl import TigrblApp, Base
4
+ from tigrbl.engine.shortcuts import mem
5
+ from tigrbl.orm.mixins import GUIDPk
6
+ from tigrbl.types import App
7
+ from httpx import ASGITransport, AsyncClient
8
+ from sqlalchemy import Column, ForeignKey, String
9
+ from tigrbl.types import PgUUID
10
+
11
+
12
+ @pytest_asyncio.fixture
13
+ async def three_level_api_client(db_mode):
14
+ Base.metadata.clear()
15
+ Base.registry.dispose()
16
+
17
+ class Company(Base, GUIDPk):
18
+ __tablename__ = "companies"
19
+ name = Column(String, nullable=False)
20
+
21
+ class Department(Base, GUIDPk):
22
+ __tablename__ = "departments"
23
+ company_id = Column(
24
+ PgUUID(as_uuid=True), ForeignKey("companies.id"), nullable=False
25
+ )
26
+ name = Column(String, nullable=False)
27
+
28
+ @classmethod
29
+ def __tigrbl_nested_paths__(cls):
30
+ return "/company/{company_id}/department"
31
+
32
+ class Employee(Base, GUIDPk):
33
+ __tablename__ = "employees"
34
+ company_id = Column(
35
+ PgUUID(as_uuid=True), ForeignKey("companies.id"), nullable=False
36
+ )
37
+ department_id = Column(
38
+ PgUUID(as_uuid=True), ForeignKey("departments.id"), nullable=False
39
+ )
40
+ name = Column(String, nullable=False)
41
+
42
+ @classmethod
43
+ def __tigrbl_nested_paths__(cls):
44
+ return "/company/{company_id}/department/{department_id}/employee"
45
+
46
+ if db_mode == "async":
47
+ pytest.skip("async database mode is currently unsupported")
48
+ else:
49
+ api = TigrblApp(engine=mem(async_=False))
50
+ api.include_models([Company, Department, Employee])
51
+ api.initialize()
52
+
53
+ app = App()
54
+ app.include_router(api.router)
55
+ transport = ASGITransport(app=app)
56
+ client = AsyncClient(transport=transport, base_url="http://test")
57
+ return client
58
+
59
+
60
+ @pytest.mark.i9n
61
+ @pytest.mark.asyncio
62
+ async def test_nested_routing_depth(three_level_api_client):
63
+ client = three_level_api_client
64
+
65
+ # Create company
66
+ res = await client.post("/company", json={"name": "Acme"})
67
+ assert res.status_code == 201
68
+ company_id = res.json()["id"]
69
+
70
+ # Create department
71
+ res = await client.post(
72
+ f"/company/{company_id}/department",
73
+ json={"name": "Engineering"},
74
+ )
75
+ assert res.status_code == 201
76
+ department_id = res.json()["id"]
77
+
78
+ # Create employee
79
+ res = await client.post(
80
+ f"/company/{company_id}/department/{department_id}/employee",
81
+ json={"name": "Alice"},
82
+ )
83
+ assert res.status_code == 201
84
+ employee_id = res.json()["id"]
85
+
86
+ # Verify generated REST paths and HTTP methods
87
+ paths = (await client.get("/openapi.json")).json()["paths"]
88
+ expected = {
89
+ "/company": {"post", "get", "delete"},
90
+ "/company/{item_id}": {"get", "patch", "delete"},
91
+ "/company/{company_id}/department": {"post", "get", "delete"},
92
+ "/company/{company_id}/department/{item_id}": {"get", "patch", "delete"},
93
+ "/company/{company_id}/department/{department_id}/employee": {
94
+ "post",
95
+ "get",
96
+ "delete",
97
+ },
98
+ "/company/{company_id}/department/{department_id}/employee/{item_id}": {
99
+ "get",
100
+ "patch",
101
+ "delete",
102
+ },
103
+ }
104
+ for path, verbs in expected.items():
105
+ assert path in paths
106
+ for verb in verbs:
107
+ assert verb in paths[path]
108
+
109
+ # Confirm nested routes resolve to correct handlers
110
+ res = await client.get(f"/company/{company_id}/department/{department_id}")
111
+ assert res.status_code == 200
112
+ assert res.json()["id"] == department_id
113
+
114
+ res = await client.get(
115
+ f"/company/{company_id}/department/{department_id}/employee/{employee_id}"
116
+ )
117
+ assert res.status_code == 200
118
+ assert res.json()["id"] == employee_id