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,81 @@
1
+ import pytest
2
+ from tigrbl import TigrblApp, Base
3
+ from tigrbl.engine.shortcuts import mem
4
+ from tigrbl.orm.mixins import GUIDPk
5
+ from tigrbl.specs import F, S, acol
6
+ from tigrbl.types import App, Mapped, String
7
+ from httpx import ASGITransport, AsyncClient
8
+
9
+
10
+ class Widget(Base, GUIDPk):
11
+ __tablename__ = "widgets_example_presence"
12
+ name: Mapped[str] = acol(
13
+ storage=S(String, nullable=False), field=F(constraints={"examples": ["foo"]})
14
+ )
15
+
16
+
17
+ def _resolve_schema(spec, schema):
18
+ if "$ref" in schema:
19
+ ref = schema["$ref"].split("/")[-1]
20
+ return spec["components"]["schemas"][ref]
21
+ if "anyOf" in schema:
22
+ return _resolve_schema(spec, schema["anyOf"][0])
23
+ if "items" in schema:
24
+ item = _resolve_schema(spec, schema["items"])
25
+ schema["items"] = item
26
+ if "examples" not in schema and "examples" in item:
27
+ schema["examples"] = [item["examples"][0]]
28
+ return schema
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ @pytest.mark.i9n
33
+ async def test_openapi_examples_and_schemas_present(db_mode):
34
+ fastapi_app = App()
35
+ engine = mem() if db_mode == "async" else mem(async_=False)
36
+ api = TigrblApp(engine=engine)
37
+ api.include_model(Widget)
38
+ if db_mode == "async":
39
+ await api.initialize()
40
+ else:
41
+ api.initialize()
42
+ api.mount_jsonrpc()
43
+ fastapi_app.include_router(api.router)
44
+
45
+ transport = ASGITransport(app=fastapi_app)
46
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
47
+ spec = (await client.get("/openapi.json")).json()
48
+
49
+ path = f"/{Widget.__name__.lower()}"
50
+ create_req = spec["paths"][path]["post"]["requestBody"]["content"][
51
+ "application/json"
52
+ ]["schema"]
53
+ create_resp = spec["paths"][path]["post"]["responses"]["201"]["content"][
54
+ "application/json"
55
+ ]["schema"]
56
+ create_req = _resolve_schema(spec, create_req)
57
+ create_resp = _resolve_schema(spec, create_resp)
58
+ assert create_req["properties"]["name"]["examples"][0] == "foo"
59
+ assert create_resp["properties"]["name"]["examples"][0] == "foo"
60
+
61
+ expected = {
62
+ "WidgetClearResponse",
63
+ "WidgetCreateRequest",
64
+ "WidgetCreateResponse",
65
+ "WidgetDeleteResponse",
66
+ "WidgetListResponse",
67
+ "WidgetReadResponse",
68
+ "WidgetReplaceRequest",
69
+ "WidgetReplaceResponse",
70
+ "WidgetUpdateRequest",
71
+ "WidgetUpdateResponse",
72
+ }
73
+ assert expected <= set(spec["components"]["schemas"])
74
+
75
+ assert hasattr(api.schemas, "Widget")
76
+ widget_ns = getattr(api.schemas, "Widget")
77
+ for alias in ["create", "read", "update", "replace", "delete", "list", "clear"]:
78
+ assert hasattr(widget_ns, alias)
79
+ op_ns = getattr(widget_ns, alias)
80
+ assert hasattr(op_ns, "in_")
81
+ assert hasattr(op_ns, "out")
@@ -0,0 +1,193 @@
1
+ import asyncio
2
+
3
+ import pytest
4
+ from tigrbl.types import App
5
+ from sqlalchemy import String, create_engine
6
+ from sqlalchemy.orm import sessionmaker
7
+
8
+ from tigrbl import core as _core
9
+ from tigrbl.bindings.model import bind
10
+ from tigrbl.hook import hook_ctx
11
+ from tigrbl.op.types import PHASES
12
+ from tigrbl.runtime import system as runtime_system
13
+ from tigrbl.runtime.kernel import build_phase_chains
14
+ from tigrbl.specs import IO, S, acol
15
+ from tigrbl.orm.tables import Base
16
+ from tigrbl.orm.mixins import GUIDPk
17
+
18
+
19
+ # --- models --------------------------------------------------------------------
20
+
21
+
22
+ # NOTE:
23
+ # Historically this test called ``Base.metadata.clear()`` at import time to
24
+ # ensure a pristine declarative registry. When the test module is imported as
25
+ # part of the full suite, clearing the global metadata wipes out tables defined
26
+ # by earlier tests which still rely on the shared ``Base``. Subsequent tests
27
+ # would then fail with missing table/column errors (manifesting as HTTP 503
28
+ # responses) because their models lost their metadata. The table names used in
29
+ # this module are unique, so we can simply avoid clearing the global metadata to
30
+ # preserve isolation without impacting other tests.
31
+
32
+
33
+ class Gadget(Base, GUIDPk):
34
+ __tablename__ = "gadgets_opspec"
35
+ __allow_unmapped__ = True
36
+
37
+ name = acol(
38
+ storage=S(type_=String, nullable=False, default="anon"),
39
+ io=IO(in_verbs=("create",), out_verbs=("read",)),
40
+ )
41
+
42
+
43
+ class Hooked(Base, GUIDPk):
44
+ __tablename__ = "hooked_opspec"
45
+ __allow_unmapped__ = True
46
+
47
+ name = acol(
48
+ storage=S(type_=String, nullable=False),
49
+ io=IO(in_verbs=("create",), out_verbs=("read",)),
50
+ )
51
+
52
+ @hook_ctx(ops="create", phase="PRE_HANDLER")
53
+ def inject_name(cls, ctx):
54
+ payload = dict(ctx.get("payload") or {})
55
+ payload.setdefault("name", "hooked")
56
+ ctx["payload"] = payload
57
+
58
+
59
+ # --- helpers -------------------------------------------------------------------
60
+
61
+ GADGET_TABLE = Gadget.__table__
62
+ HOOKED_TABLE = Hooked.__table__
63
+
64
+
65
+ def _ensure_tables():
66
+ for model, table in ((Gadget, GADGET_TABLE), (Hooked, HOOKED_TABLE)):
67
+ if not hasattr(model, "__table__"):
68
+ model.__table__ = table # type: ignore[attr-defined]
69
+ if table.key not in Base.metadata.tables:
70
+ Base.metadata._add_table(table.name, table.schema, table)
71
+ if not hasattr(model, "__mapper__"):
72
+ Base.registry.map_imperatively(model, table)
73
+
74
+
75
+ def _fresh_session():
76
+ engine = create_engine("sqlite:///:memory:")
77
+ _ensure_tables()
78
+ Base.metadata.create_all(bind=engine, tables=[Gadget.__table__, Hooked.__table__])
79
+ return sessionmaker(bind=engine)()
80
+
81
+
82
+ # --- tests ---------------------------------------------------------------------
83
+
84
+
85
+ @pytest.mark.i9n
86
+ def test_request_and_response_schemas():
87
+ _ensure_tables()
88
+ bind(Gadget)
89
+ assert hasattr(Gadget.schemas, "create")
90
+ assert hasattr(Gadget.schemas.create, "in_")
91
+ assert hasattr(Gadget.schemas, "read")
92
+ assert hasattr(Gadget.schemas.read, "out")
93
+
94
+
95
+ @pytest.mark.i9n
96
+ def test_columns_bound():
97
+ _ensure_tables()
98
+ bind(Gadget)
99
+ assert "name" in Gadget.__table__.c
100
+ assert "name" in Gadget.__tigrbl_cols__
101
+
102
+
103
+ @pytest.mark.i9n
104
+ def test_defaults_value_resolution():
105
+ _ensure_tables()
106
+ bind(Gadget)
107
+ db = _fresh_session()
108
+ obj = asyncio.run(_core.create(Gadget, db=db, data={}))
109
+ assert obj.name == "anon"
110
+
111
+
112
+ @pytest.mark.i9n
113
+ def test_internal_model_opspec_binding():
114
+ _ensure_tables()
115
+ bind(Gadget)
116
+ sp = Gadget.opspecs.by_alias["create"][0]
117
+ assert sp.table is Gadget
118
+
119
+
120
+ @pytest.mark.i9n
121
+ def test_openapi_includes_path():
122
+ _ensure_tables()
123
+ bind(Gadget)
124
+ app = App()
125
+ app.include_router(Gadget.rest.router)
126
+ schema = app.openapi()
127
+ assert "/gadget" in schema["paths"]
128
+
129
+
130
+ @pytest.mark.i9n
131
+ def test_storage_and_sqlalchemy_persist():
132
+ _ensure_tables()
133
+ bind(Gadget)
134
+ db = _fresh_session()
135
+ asyncio.run(_core.create(Gadget, db=db, data={"name": "stored"}))
136
+ fetched = db.query(Gadget).one()
137
+ assert fetched.name == "stored"
138
+
139
+
140
+ @pytest.mark.i9n
141
+ def test_rest_routes_bound():
142
+ _ensure_tables()
143
+ session = _fresh_session()
144
+
145
+ def get_db():
146
+ return session
147
+
148
+ Gadget.__tigrbl_get_db__ = staticmethod(get_db) # type: ignore[attr-defined]
149
+ bind(Gadget)
150
+ app = App()
151
+ app.include_router(Gadget.rest.router)
152
+ paths = {route.path for route in app.router.routes}
153
+ assert "/gadget" in paths
154
+
155
+
156
+ @pytest.mark.i9n
157
+ def test_rpc_method_bound():
158
+ _ensure_tables()
159
+ bind(Gadget)
160
+ assert hasattr(Gadget.rpc, "create")
161
+
162
+
163
+ @pytest.mark.i9n
164
+ def test_core_crud_handler_used():
165
+ _ensure_tables()
166
+ bind(Gadget)
167
+ step = Gadget.hooks.create.HANDLER[0]
168
+ assert step.__qualname__ == _core.create.__qualname__
169
+
170
+
171
+ @pytest.mark.i9n
172
+ def test_hook_execution():
173
+ _ensure_tables()
174
+ bind(Hooked)
175
+ assert Hooked.hooks.create.PRE_HANDLER
176
+
177
+
178
+ @pytest.mark.i9n
179
+ def test_atom_injection():
180
+ _ensure_tables()
181
+ bind(Gadget)
182
+ chains = build_phase_chains(Gadget, "create")
183
+ non_handler = [ph for ph in PHASES if ph != "HANDLER" and chains.get(ph)]
184
+ # atom discovery injects steps into additional phases beyond the handler
185
+ assert non_handler
186
+
187
+
188
+ @pytest.mark.i9n
189
+ def test_system_step_registry():
190
+ subjects = runtime_system.subjects()
191
+ assert ("txn", "begin") in subjects
192
+ assert ("handler", "crud") in subjects
193
+ assert ("txn", "commit") in subjects
@@ -0,0 +1,173 @@
1
+ from tigrbl.types import App, HTTPException, Request, Security
2
+ from typing import Iterable
3
+
4
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
5
+ from fastapi.testclient import TestClient
6
+ import pytest
7
+ import uuid
8
+ from sqlalchemy import Column, String
9
+
10
+ from tigrbl import TigrblApp, Base
11
+ from tigrbl.orm.mixins import GUIDPk
12
+ from tigrbl.orm.mixins.ownable import Ownable, OwnerPolicy
13
+ from tigrbl.orm.mixins.tenant_bound import TenantBound, TenantPolicy
14
+ from tigrbl.config.constants import TIGRBL_AUTH_CONTEXT_ATTR
15
+ from tigrbl.types.authn_abc import AuthNProvider
16
+
17
+
18
+ class DummyAuth(AuthNProvider):
19
+ def __init__(self, user_id: uuid.UUID, tenant_id: uuid.UUID):
20
+ self.user_id = user_id
21
+ self.tenant_id = tenant_id
22
+
23
+ async def get_principal(
24
+ self,
25
+ request: Request,
26
+ creds: HTTPAuthorizationCredentials = Security(HTTPBearer()),
27
+ ):
28
+ if creds.credentials != "secret":
29
+ raise HTTPException(status_code=401)
30
+ principal = {"user_id": self.user_id, "tenant_id": self.tenant_id}
31
+ request.state.principal = principal
32
+ setattr(request.state, TIGRBL_AUTH_CONTEXT_ATTR, principal)
33
+ return principal
34
+
35
+
36
+ def _client_for_owner(
37
+ policy: OwnerPolicy,
38
+ user_id: uuid.UUID,
39
+ tenant_id: uuid.UUID,
40
+ extra_user_ids: Iterable[uuid.UUID] | None = None,
41
+ ) -> TestClient:
42
+ Base.metadata.clear()
43
+
44
+ class User(Base, GUIDPk):
45
+ __tablename__ = "users"
46
+ __table_args__ = {"schema": "main"}
47
+ name = Column(String, nullable=False)
48
+
49
+ class Item(Base, GUIDPk, Ownable):
50
+ __tablename__ = "items"
51
+ __table_args__ = {"schema": "main"}
52
+ name = Column(String, nullable=False)
53
+ __tigrbl_owner_policy__ = policy
54
+
55
+ from tigrbl.engine.shortcuts import mem
56
+ from tigrbl.engine.engine_spec import EngineSpec
57
+ from tigrbl.engine._engine import Engine
58
+
59
+ engine = Engine(EngineSpec.from_any(mem(async_=False)))
60
+ db_engine, _ = engine.raw()
61
+ Base.metadata.create_all(bind=db_engine)
62
+
63
+ with engine.session() as session:
64
+ session.execute(User.__table__.insert().values(id=user_id, name="owner"))
65
+ for extra in extra_user_ids or []:
66
+ session.execute(User.__table__.insert().values(id=extra, name="extra"))
67
+ session.commit()
68
+
69
+ authn = DummyAuth(user_id, tenant_id)
70
+ api = TigrblApp(engine=engine)
71
+ api.set_auth(authn=authn.get_principal)
72
+ api.include_models([User, Item])
73
+ app = App()
74
+ app.include_router(api.router)
75
+ api.initialize()
76
+ return TestClient(app)
77
+
78
+
79
+ @pytest.mark.i9n
80
+ def test_owner_policy_runtime_switch():
81
+ user_id = uuid.uuid4()
82
+ tenant_id = uuid.uuid4()
83
+ headers = {"Authorization": "Bearer secret"}
84
+
85
+ client = _client_for_owner(OwnerPolicy.STRICT_SERVER, user_id, tenant_id)
86
+ res = client.post(
87
+ "/item",
88
+ json={"name": "one", "owner_id": str(user_id)},
89
+ headers=headers,
90
+ )
91
+ assert res.status_code == 400
92
+
93
+ supplied = uuid.uuid4()
94
+ client = _client_for_owner(
95
+ OwnerPolicy.CLIENT_SET, user_id, tenant_id, extra_user_ids=[supplied]
96
+ )
97
+ res = client.post(
98
+ "/item",
99
+ json={"name": "two", "owner_id": str(supplied)},
100
+ headers=headers,
101
+ )
102
+ assert res.status_code == 201
103
+ assert res.json()["owner_id"] == str(supplied)
104
+
105
+
106
+ def _client_for_tenant(
107
+ policy: TenantPolicy,
108
+ user_id: uuid.UUID,
109
+ tenant_id: uuid.UUID,
110
+ extra_tenant_ids: Iterable[uuid.UUID] | None = None,
111
+ ) -> TestClient:
112
+ Base.metadata.clear()
113
+
114
+ class Tenant(Base, GUIDPk):
115
+ __tablename__ = "tenants"
116
+ __table_args__ = {"schema": "main"}
117
+ name = Column(String, nullable=False)
118
+
119
+ class Item(Base, GUIDPk, TenantBound):
120
+ __tablename__ = "items"
121
+ __table_args__ = {"schema": "main"}
122
+ name = Column(String, nullable=False)
123
+ __tigrbl_tenant_policy__ = policy
124
+
125
+ from tigrbl.engine.shortcuts import mem
126
+ from tigrbl.engine.engine_spec import EngineSpec
127
+ from tigrbl.engine._engine import Engine
128
+
129
+ engine = Engine(EngineSpec.from_any(mem(async_=False)))
130
+ db_engine, _ = engine.raw()
131
+ Base.metadata.create_all(bind=db_engine)
132
+
133
+ with engine.session() as session:
134
+ session.execute(Tenant.__table__.insert().values(id=tenant_id, name="acme"))
135
+ for extra in extra_tenant_ids or []:
136
+ session.execute(Tenant.__table__.insert().values(id=extra, name="extra"))
137
+ session.commit()
138
+
139
+ authn = DummyAuth(user_id, tenant_id)
140
+ api = TigrblApp(engine=engine)
141
+ api.set_auth(authn=authn.get_principal)
142
+ api.include_models([Tenant, Item])
143
+ app = App()
144
+ app.include_router(api.router)
145
+ api.initialize()
146
+ return TestClient(app)
147
+
148
+
149
+ @pytest.mark.i9n
150
+ def test_tenant_policy_runtime_switch():
151
+ user_id = uuid.uuid4()
152
+ tenant_id = uuid.uuid4()
153
+ headers = {"Authorization": "Bearer secret"}
154
+
155
+ client = _client_for_tenant(TenantPolicy.STRICT_SERVER, user_id, tenant_id)
156
+ res = client.post(
157
+ "/item",
158
+ json={"name": "one", "tenant_id": str(tenant_id)},
159
+ headers=headers,
160
+ )
161
+ assert res.status_code == 400
162
+
163
+ supplied = uuid.uuid4()
164
+ client = _client_for_tenant(
165
+ TenantPolicy.CLIENT_SET, user_id, tenant_id, extra_tenant_ids=[supplied]
166
+ )
167
+ res = client.post(
168
+ "/item",
169
+ json={"name": "two", "tenant_id": str(supplied)},
170
+ headers=headers,
171
+ )
172
+ assert res.status_code == 201
173
+ assert res.json()["tenant_id"] == str(supplied)
@@ -0,0 +1,74 @@
1
+ import pytest
2
+ import pytest_asyncio
3
+ from tigrbl.types import App
4
+ from httpx import ASGITransport, AsyncClient
5
+ from pydantic import Field
6
+ from sqlalchemy import Column, String
7
+ from uuid import uuid4
8
+
9
+ from tigrbl import TigrblApp, Base
10
+ from tigrbl.engine.shortcuts import mem
11
+ from tigrbl.schema import _build_schema
12
+
13
+
14
+ @pytest_asyncio.fixture()
15
+ async def api_client_with_extras(db_mode):
16
+ Base.metadata.clear()
17
+
18
+ class Widget(Base):
19
+ __tablename__ = "widgets"
20
+ id = Column(String, primary_key=True, default=lambda: str(uuid4()))
21
+ name = Column(String, nullable=False)
22
+ __tigrbl_request_extras__ = {
23
+ "*": {"token": (str | None, Field(default=None, exclude=True))},
24
+ "create": {"create_note": (str | None, Field(default=None, exclude=True))},
25
+ "update": {"update_flag": (bool | None, Field(default=None, exclude=True))},
26
+ }
27
+
28
+ if db_mode == "async":
29
+ api = TigrblApp(engine=mem())
30
+ api.include_model(Widget)
31
+ await api.initialize()
32
+ else:
33
+ api = TigrblApp(engine=mem(async_=False))
34
+ api.include_model(Widget)
35
+ api.initialize()
36
+
37
+ app = App()
38
+ app.include_router(api.router)
39
+ transport = ASGITransport(app=app)
40
+ client = AsyncClient(transport=transport, base_url="http://test")
41
+ return client, api, Widget
42
+
43
+
44
+ @pytest.mark.i9n
45
+ @pytest.mark.asyncio
46
+ async def test_request_extras_schema(api_client_with_extras):
47
+ _, _, Widget = api_client_with_extras
48
+ create_schema = _build_schema(Widget, verb="create")
49
+ update_schema = _build_schema(Widget, verb="update")
50
+ assert {"token", "create_note"} <= set(create_schema.model_fields)
51
+ assert {"token", "update_flag"} <= set(update_schema.model_fields)
52
+
53
+
54
+ @pytest.mark.i9n
55
+ @pytest.mark.asyncio
56
+ async def test_request_extras_runtime(api_client_with_extras):
57
+ client, _, _ = api_client_with_extras
58
+ res = await client.post(
59
+ "/widget",
60
+ json={"name": "w1", "token": "t", "create_note": "note"},
61
+ )
62
+ assert res.status_code == 201
63
+ body = res.json()
64
+ wid = body["id"]
65
+ assert "token" not in body and "create_note" not in body
66
+
67
+ res = await client.patch(
68
+ f"/widget/{wid}",
69
+ json={"name": "w2", "token": "t2", "update_flag": True},
70
+ )
71
+ assert res.status_code == 200
72
+
73
+ body = res.json()
74
+ assert "token" not in body and "update_flag" not in body
@@ -0,0 +1,27 @@
1
+ import pytest
2
+
3
+ from tigrbl import Base
4
+ from tigrbl.orm.mixins import GUIDPk
5
+ from tigrbl.schema import _build_schema
6
+ from tigrbl.types import Column, Field, RequestExtrasProvider, String
7
+ from tigrbl.types.request_extras_provider import list_request_extras_providers
8
+
9
+
10
+ @pytest.mark.i9n
11
+ @pytest.mark.asyncio
12
+ async def test_request_extras_provider_in_schema():
13
+ Base.metadata.clear()
14
+
15
+ class Widget(Base, GUIDPk, RequestExtrasProvider):
16
+ __tablename__ = "widgets"
17
+ name = Column(String, nullable=False)
18
+ __tigrbl_request_extras__ = {
19
+ "create": {"extra": (int | None, Field(None, exclude=True))}
20
+ }
21
+
22
+ SCreate = _build_schema(Widget, verb="create")
23
+ SRead = _build_schema(Widget, verb="read")
24
+
25
+ assert "extra" in SCreate.model_fields
26
+ assert "extra" not in SRead.model_fields
27
+ assert Widget in list_request_extras_providers()
@@ -0,0 +1,22 @@
1
+ import pytest
2
+
3
+ from tigrbl import Base
4
+ from tigrbl.orm.mixins import GUIDPk
5
+ from tigrbl.schema import _build_schema
6
+ from tigrbl.types import Column, Field, ResponseExtrasProvider, String
7
+ from tigrbl.types.response_extras_provider import list_response_extras_providers
8
+
9
+
10
+ @pytest.mark.i9n
11
+ @pytest.mark.asyncio
12
+ async def test_response_extras_provider_in_schema():
13
+ Base.metadata.clear()
14
+
15
+ class Widget(Base, GUIDPk, ResponseExtrasProvider):
16
+ __tablename__ = "widgets"
17
+ name = Column(String, nullable=False)
18
+ __tigrbl_response_extras__ = {"read": {"extra": (int | None, Field(None))}}
19
+
20
+ SRead = _build_schema(Widget, verb="read")
21
+ assert "extra" in SRead.model_fields
22
+ assert Widget in list_response_extras_providers()
@@ -0,0 +1,66 @@
1
+ import pytest
2
+ import pytest_asyncio
3
+ from tigrbl.types import App
4
+ from httpx import ASGITransport, AsyncClient
5
+ from sqlalchemy import Integer, String
6
+ from sqlalchemy.orm import Mapped
7
+
8
+ from tigrbl import TigrblApp as Tigrblv3
9
+ from tigrbl.engine.shortcuts import mem
10
+ from tigrbl.specs import F, IO, S, acol
11
+ from tigrbl.orm.tables import Base as Base3
12
+
13
+
14
+ @pytest_asyncio.fixture()
15
+ async def client_and_model():
16
+ Base3.metadata.clear()
17
+
18
+ class Widget(Base3):
19
+ __tablename__ = "widgets"
20
+ __allow_unmapped__ = True
21
+
22
+ id: Mapped[int] = acol(
23
+ storage=S(type_=Integer, primary_key=True, autoincrement=True),
24
+ io=IO(out_verbs=("read", "list")),
25
+ )
26
+ name: Mapped[str] = acol(
27
+ storage=S(type_=String, nullable=False),
28
+ field=F(required_in=("create",)),
29
+ io=IO(in_verbs=("create", "update")),
30
+ )
31
+
32
+ __tigrbl_cols__ = {"id": id, "name": name}
33
+
34
+ app = App()
35
+ api = Tigrblv3(engine=mem())
36
+ api.include_model(Widget, prefix="")
37
+ await api.initialize()
38
+ # Remove output schemas to trigger fallback serialization
39
+ Widget.schemas.read.out = None
40
+ Widget.schemas.list.out = None
41
+
42
+ app.include_router(api.router)
43
+ transport = ASGITransport(app=app)
44
+ client = AsyncClient(transport=transport, base_url="http://test")
45
+ try:
46
+ yield client, Widget
47
+ finally:
48
+ await client.aclose()
49
+
50
+
51
+ @pytest.mark.i9n
52
+ @pytest.mark.asyncio
53
+ async def test_rest_read_and_list_without_out_schema(client_and_model):
54
+ client, _ = client_and_model
55
+ created = await client.post("/widget", json={"name": "A"})
56
+ item_id = created.json()["id"]
57
+
58
+ resp = await client.get(f"/widget/{item_id}")
59
+ assert resp.status_code == 200
60
+ assert resp.json()["id"] == item_id
61
+
62
+ resp_list = await client.get("/widget")
63
+ assert resp_list.status_code == 200
64
+ data = resp_list.json()
65
+ assert isinstance(data, list)
66
+ assert data[0]["id"] == item_id