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,368 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from tigrbl.types import App, SimpleNamespace
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
from sqlalchemy import create_engine
|
|
5
|
+
from sqlalchemy.orm import Mapped, sessionmaker
|
|
6
|
+
from sqlalchemy.pool import StaticPool
|
|
7
|
+
|
|
8
|
+
from tigrbl import TigrblApp
|
|
9
|
+
from tigrbl.engine.shortcuts import engine as engine_factory, mem
|
|
10
|
+
from tigrbl.bindings.model import bind
|
|
11
|
+
from tigrbl.bindings.rest.router import _build_router
|
|
12
|
+
from tigrbl.bindings.rpc import register_and_attach
|
|
13
|
+
from tigrbl.op import OpSpec
|
|
14
|
+
from tigrbl.runtime.atoms.resolve import assemble
|
|
15
|
+
from tigrbl.runtime.atoms.schema import collect_in, collect_out
|
|
16
|
+
from tigrbl.runtime.kernel import _default_kernel as K, build_phase_chains
|
|
17
|
+
from tigrbl.specs import F, IO, S, acol, vcol
|
|
18
|
+
from tigrbl.orm.tables import Base
|
|
19
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
20
|
+
from tigrbl.types import Integer as IntType, String as StrType
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.i9n
|
|
24
|
+
def test_request_and_response_schemas_respect_iospec_aliases():
|
|
25
|
+
class Thing(Base):
|
|
26
|
+
__tablename__ = "iospec_schema_i9n"
|
|
27
|
+
__allow_unmapped__ = True
|
|
28
|
+
|
|
29
|
+
id = acol(
|
|
30
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
31
|
+
io=IO(out_verbs=("read",)),
|
|
32
|
+
)
|
|
33
|
+
name = acol(
|
|
34
|
+
storage=S(type_=StrType, nullable=False),
|
|
35
|
+
io=IO(
|
|
36
|
+
in_verbs=("create",),
|
|
37
|
+
out_verbs=("read",),
|
|
38
|
+
alias_in="first_name",
|
|
39
|
+
alias_out="firstName",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
bind(Thing)
|
|
44
|
+
specs = Thing.__tigrbl_cols__
|
|
45
|
+
ctx_in = SimpleNamespace(
|
|
46
|
+
opview=K._compile_opview_from_specs(specs, SimpleNamespace(alias="create")),
|
|
47
|
+
op="create",
|
|
48
|
+
temp={},
|
|
49
|
+
)
|
|
50
|
+
collect_in.run(None, ctx_in)
|
|
51
|
+
schema_in = ctx_in.temp["schema_in"]
|
|
52
|
+
assert "id" not in schema_in["by_field"]
|
|
53
|
+
assert schema_in["by_field"]["name"]["alias_in"] == "first_name"
|
|
54
|
+
|
|
55
|
+
ctx_out = SimpleNamespace(
|
|
56
|
+
opview=K._compile_opview_from_specs(specs, SimpleNamespace(alias="read")),
|
|
57
|
+
op="read",
|
|
58
|
+
temp={},
|
|
59
|
+
)
|
|
60
|
+
collect_out.run(None, ctx_out)
|
|
61
|
+
schema_out = ctx_out.temp["schema_out"]
|
|
62
|
+
assert "id" in schema_out["by_field"]
|
|
63
|
+
assert schema_out["by_field"]["name"]["alias_out"] == "firstName"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.i9n
|
|
67
|
+
def test_columns_materialized_for_acol():
|
|
68
|
+
class Thing(Base):
|
|
69
|
+
__tablename__ = "iospec_columns_i9n"
|
|
70
|
+
__allow_unmapped__ = True
|
|
71
|
+
|
|
72
|
+
id = acol(
|
|
73
|
+
storage=S(type_=IntType, primary_key=True), io=IO(out_verbs=("read",))
|
|
74
|
+
)
|
|
75
|
+
nick: Mapped[str] = vcol(field=F(py_type=str), io=IO(out_verbs=("read",)))
|
|
76
|
+
|
|
77
|
+
bind(Thing)
|
|
78
|
+
assert "id" in Thing.__table__.c
|
|
79
|
+
assert "nick" in Thing.__table__.c
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.i9n
|
|
83
|
+
def test_default_factory_resolves_missing_value():
|
|
84
|
+
class Thing(Base):
|
|
85
|
+
__tablename__ = "iospec_defaults_i9n"
|
|
86
|
+
__allow_unmapped__ = True
|
|
87
|
+
|
|
88
|
+
id = acol(
|
|
89
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
90
|
+
io=IO(out_verbs=("read",)),
|
|
91
|
+
)
|
|
92
|
+
created = acol(
|
|
93
|
+
storage=S(type_=StrType, nullable=False),
|
|
94
|
+
io=IO(in_verbs=("create",)),
|
|
95
|
+
default_factory=lambda ctx: "now",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
bind(Thing)
|
|
99
|
+
specs = Thing.__tigrbl_cols__
|
|
100
|
+
ctx = SimpleNamespace(
|
|
101
|
+
opview=K._compile_opview_from_specs(specs, SimpleNamespace(alias="create")),
|
|
102
|
+
op="create",
|
|
103
|
+
temp={"in_values": {}},
|
|
104
|
+
persist=True,
|
|
105
|
+
)
|
|
106
|
+
assemble.run(None, ctx)
|
|
107
|
+
assembled = ctx.temp["assembled_values"]
|
|
108
|
+
assert assembled["created"] == "now"
|
|
109
|
+
assert "created" in ctx.temp["used_default_factory"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.i9n
|
|
113
|
+
def test_binding_attaches_internal_model_namespaces():
|
|
114
|
+
class Thing(Base):
|
|
115
|
+
__tablename__ = "iospec_internal_i9n"
|
|
116
|
+
__allow_unmapped__ = True
|
|
117
|
+
|
|
118
|
+
id = acol(
|
|
119
|
+
storage=S(type_=IntType, primary_key=True), io=IO(out_verbs=("read",))
|
|
120
|
+
)
|
|
121
|
+
name = acol(
|
|
122
|
+
storage=S(type_=StrType, nullable=False),
|
|
123
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
api = TigrblApp()
|
|
127
|
+
api.include_model(Thing, mount_router=False)
|
|
128
|
+
assert "Thing" in api.models
|
|
129
|
+
assert hasattr(api.schemas, "Thing")
|
|
130
|
+
assert "name" in Thing.__tigrbl_cols__
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.i9n
|
|
134
|
+
def test_openapi_reflects_io_verbs():
|
|
135
|
+
class Widget(Base, GUIDPk):
|
|
136
|
+
__tablename__ = "iospec_openapi_i9n"
|
|
137
|
+
__allow_unmapped__ = True
|
|
138
|
+
|
|
139
|
+
name = acol(
|
|
140
|
+
storage=S(type_=StrType, nullable=False),
|
|
141
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
sp_create = OpSpec(alias="create", target="create")
|
|
145
|
+
sp_read = OpSpec(alias="read", target="read")
|
|
146
|
+
router = _build_router(Widget, [sp_create, sp_read])
|
|
147
|
+
app = App()
|
|
148
|
+
app.include_router(router)
|
|
149
|
+
spec = app.openapi()
|
|
150
|
+
|
|
151
|
+
props_create = spec["components"]["schemas"]["WidgetCreateRequest"]["properties"]
|
|
152
|
+
assert "name" in props_create
|
|
153
|
+
assert "id" not in props_create
|
|
154
|
+
|
|
155
|
+
props_read = spec["components"]["schemas"]["WidgetReadResponse"]["properties"]
|
|
156
|
+
assert "name" in props_read
|
|
157
|
+
assert "id" in props_read
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.i9n
|
|
161
|
+
def test_storage_and_sqlalchemy_integration():
|
|
162
|
+
engine = create_engine(
|
|
163
|
+
"sqlite:///:memory:",
|
|
164
|
+
connect_args={"check_same_thread": False},
|
|
165
|
+
poolclass=StaticPool,
|
|
166
|
+
)
|
|
167
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
|
168
|
+
Base.metadata.clear()
|
|
169
|
+
|
|
170
|
+
class Thing(Base):
|
|
171
|
+
__tablename__ = "iospec_storage_i9n"
|
|
172
|
+
__allow_unmapped__ = True
|
|
173
|
+
|
|
174
|
+
id = acol(
|
|
175
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
176
|
+
io=IO(out_verbs=("read",)),
|
|
177
|
+
)
|
|
178
|
+
name = acol(
|
|
179
|
+
storage=S(type_=StrType, nullable=False),
|
|
180
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
bind(Thing)
|
|
184
|
+
Base.metadata.create_all(engine)
|
|
185
|
+
|
|
186
|
+
with SessionLocal() as session:
|
|
187
|
+
obj = Thing(name="foo")
|
|
188
|
+
session.add(obj)
|
|
189
|
+
session.commit()
|
|
190
|
+
session.refresh(obj)
|
|
191
|
+
assert obj.id is not None
|
|
192
|
+
stored = session.get(Thing, obj.id)
|
|
193
|
+
assert stored.name == "foo"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pytest.mark.i9n
|
|
197
|
+
def test_rest_call_respects_aliases():
|
|
198
|
+
eng = engine_factory(mem(async_=False))
|
|
199
|
+
|
|
200
|
+
class Thing(Base):
|
|
201
|
+
__tablename__ = "iospec_rest_i9n"
|
|
202
|
+
__allow_unmapped__ = True
|
|
203
|
+
|
|
204
|
+
id = acol(
|
|
205
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
206
|
+
io=IO(out_verbs=("read",)),
|
|
207
|
+
)
|
|
208
|
+
name = acol(
|
|
209
|
+
storage=S(type_=StrType, nullable=False),
|
|
210
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
api = TigrblApp(engine=eng)
|
|
214
|
+
api.include_model(Thing)
|
|
215
|
+
Base.metadata.create_all(eng.raw()[0])
|
|
216
|
+
client = TestClient(api)
|
|
217
|
+
|
|
218
|
+
resp = client.post("/thing", json={"name": "Ada"})
|
|
219
|
+
data = resp.json()
|
|
220
|
+
assert data["name"] == "Ada"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.i9n
|
|
224
|
+
@pytest.mark.asyncio
|
|
225
|
+
async def test_rpc_call_uses_schemas():
|
|
226
|
+
engine = create_engine(
|
|
227
|
+
"sqlite:///:memory:",
|
|
228
|
+
connect_args={"check_same_thread": False},
|
|
229
|
+
poolclass=StaticPool,
|
|
230
|
+
)
|
|
231
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
|
232
|
+
Base.metadata.clear()
|
|
233
|
+
|
|
234
|
+
class Thing(Base):
|
|
235
|
+
__tablename__ = "iospec_rpc_i9n"
|
|
236
|
+
__allow_unmapped__ = True
|
|
237
|
+
|
|
238
|
+
id = acol(
|
|
239
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
240
|
+
io=IO(out_verbs=("read",)),
|
|
241
|
+
)
|
|
242
|
+
name = acol(
|
|
243
|
+
storage=S(type_=StrType, nullable=False),
|
|
244
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
bind(Thing)
|
|
248
|
+
register_and_attach(Thing, [OpSpec(alias="create", target="create")])
|
|
249
|
+
Base.metadata.create_all(engine)
|
|
250
|
+
|
|
251
|
+
with SessionLocal() as session:
|
|
252
|
+
result = await Thing.rpc.create({"name": "Bob"}, db=session)
|
|
253
|
+
assert result["name"] == "Bob"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.mark.i9n
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_core_crud_helpers_operate():
|
|
259
|
+
eng = engine_factory(mem(async_=False))
|
|
260
|
+
|
|
261
|
+
class Thing(Base):
|
|
262
|
+
__tablename__ = "iospec_core_i9n"
|
|
263
|
+
__allow_unmapped__ = True
|
|
264
|
+
|
|
265
|
+
id = acol(
|
|
266
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
267
|
+
io=IO(out_verbs=("read",)),
|
|
268
|
+
)
|
|
269
|
+
name = acol(
|
|
270
|
+
storage=S(type_=StrType, nullable=False),
|
|
271
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
api = TigrblApp(engine=eng)
|
|
275
|
+
api.include_model(Thing)
|
|
276
|
+
Base.metadata.create_all(eng.raw()[0])
|
|
277
|
+
|
|
278
|
+
with eng.session() as session:
|
|
279
|
+
created = await api.core.Thing.create({"name": "Zed"}, db=session)
|
|
280
|
+
obj = await api.core.Thing.read({"id": created["id"]}, db=session)
|
|
281
|
+
assert obj["name"] == "Zed"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.mark.i9n
|
|
285
|
+
@pytest.mark.asyncio
|
|
286
|
+
async def test_hooks_trigger_with_iospec():
|
|
287
|
+
engine = create_engine(
|
|
288
|
+
"sqlite:///:memory:",
|
|
289
|
+
connect_args={"check_same_thread": False},
|
|
290
|
+
poolclass=StaticPool,
|
|
291
|
+
)
|
|
292
|
+
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
|
|
293
|
+
|
|
294
|
+
called = {}
|
|
295
|
+
|
|
296
|
+
async def before(ctx):
|
|
297
|
+
called["hit"] = True
|
|
298
|
+
|
|
299
|
+
class Thing(Base):
|
|
300
|
+
__tablename__ = "iospec_hooks_i9n"
|
|
301
|
+
__allow_unmapped__ = True
|
|
302
|
+
|
|
303
|
+
id = acol(
|
|
304
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
305
|
+
io=IO(out_verbs=("read",)),
|
|
306
|
+
)
|
|
307
|
+
name = acol(
|
|
308
|
+
storage=S(type_=StrType, nullable=False),
|
|
309
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
310
|
+
)
|
|
311
|
+
__tigrbl_hooks__ = {"create": {"PRE_HANDLER": [before]}}
|
|
312
|
+
|
|
313
|
+
bind(Thing)
|
|
314
|
+
register_and_attach(Thing, [OpSpec(alias="create", target="create")])
|
|
315
|
+
Base.metadata.create_all(engine)
|
|
316
|
+
|
|
317
|
+
with SessionLocal() as session:
|
|
318
|
+
await Thing.rpc.create({"name": "hi"}, db=session)
|
|
319
|
+
assert called.get("hit") is True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@pytest.mark.i9n
|
|
323
|
+
def test_atoms_execute_with_iospec():
|
|
324
|
+
class Thing(Base):
|
|
325
|
+
__tablename__ = "iospec_atoms_i9n"
|
|
326
|
+
__allow_unmapped__ = True
|
|
327
|
+
|
|
328
|
+
id = acol(
|
|
329
|
+
storage=S(type_=IntType, primary_key=True, autoincrement=True),
|
|
330
|
+
io=IO(out_verbs=("read",)),
|
|
331
|
+
)
|
|
332
|
+
name = acol(
|
|
333
|
+
storage=S(type_=StrType, nullable=False),
|
|
334
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
bind(Thing)
|
|
338
|
+
specs = Thing.__tigrbl_cols__
|
|
339
|
+
ctx = SimpleNamespace(
|
|
340
|
+
opview=K._compile_opview_from_specs(specs, SimpleNamespace(alias="create")),
|
|
341
|
+
op="create",
|
|
342
|
+
temp={"in_values": {"name": "x"}},
|
|
343
|
+
persist=True,
|
|
344
|
+
)
|
|
345
|
+
collect_in.run(None, ctx)
|
|
346
|
+
assemble.run(None, ctx)
|
|
347
|
+
collect_out.run(None, ctx)
|
|
348
|
+
assert ctx.temp["assembled_values"]["name"] == "x"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@pytest.mark.i9n
|
|
352
|
+
def test_system_phase_chain_includes_system_steps():
|
|
353
|
+
class Thing(Base):
|
|
354
|
+
__tablename__ = "iospec_system_i9n"
|
|
355
|
+
__allow_unmapped__ = True
|
|
356
|
+
|
|
357
|
+
id = acol(
|
|
358
|
+
storage=S(type_=IntType, primary_key=True), io=IO(out_verbs=("read",))
|
|
359
|
+
)
|
|
360
|
+
name = acol(
|
|
361
|
+
storage=S(type_=StrType, nullable=False),
|
|
362
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
bind(Thing)
|
|
366
|
+
chains = build_phase_chains(Thing, "create")
|
|
367
|
+
assert "HANDLER" in chains
|
|
368
|
+
assert any(chains[ph] for ph in chains)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import pytest_asyncio
|
|
3
|
+
from httpx import ASGITransport, AsyncClient
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
from tigrbl import TigrblApp
|
|
8
|
+
from tigrbl.engine import resolver as _resolver
|
|
9
|
+
from tigrbl.engine.shortcuts import mem
|
|
10
|
+
from tigrbl.orm.tables import Base
|
|
11
|
+
from tigrbl.orm.mixins import GUIDPk
|
|
12
|
+
from tigrbl.specs import IO, S, acol
|
|
13
|
+
from tigrbl.types import App, String, UUID
|
|
14
|
+
from tigrbl.core import crud
|
|
15
|
+
from tigrbl.runtime.atoms.resolve import assemble
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Widget(Base, GUIDPk):
|
|
19
|
+
__tablename__ = "widgets"
|
|
20
|
+
|
|
21
|
+
name = acol(
|
|
22
|
+
storage=S(type_=String, nullable=False),
|
|
23
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
24
|
+
)
|
|
25
|
+
secret = acol(
|
|
26
|
+
storage=S(type_=String, nullable=True),
|
|
27
|
+
io=IO(in_verbs=("create",), out_verbs=(), allow_out=False),
|
|
28
|
+
)
|
|
29
|
+
created_at = acol(
|
|
30
|
+
storage=S(type_=String, nullable=False),
|
|
31
|
+
io=IO(in_verbs=("create",), out_verbs=("read",)),
|
|
32
|
+
default_factory=lambda ctx: "now",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest_asyncio.fixture
|
|
37
|
+
async def widget_setup():
|
|
38
|
+
app = App()
|
|
39
|
+
api = TigrblApp(engine=mem(async_=False))
|
|
40
|
+
api.include_model(Widget, prefix="/widget")
|
|
41
|
+
api.mount_jsonrpc(prefix="/rpc")
|
|
42
|
+
api.attach_diagnostics(prefix="/system")
|
|
43
|
+
api.initialize()
|
|
44
|
+
app.include_router(api.router)
|
|
45
|
+
|
|
46
|
+
prov = _resolver.resolve_provider()
|
|
47
|
+
SessionLocal = prov.session
|
|
48
|
+
|
|
49
|
+
transport = ASGITransport(app=app)
|
|
50
|
+
client = AsyncClient(transport=transport, base_url="http://test")
|
|
51
|
+
yield client, api, SessionLocal
|
|
52
|
+
await client.aclose()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.i9n
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_request_schema_reflects_io_spec(widget_setup):
|
|
58
|
+
_, api, _ = widget_setup
|
|
59
|
+
schema = api.schemas.Widget.create.in_.model_json_schema()
|
|
60
|
+
assert set(schema["properties"]) == {"name", "secret", "created_at"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.i9n
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_response_schema_reflects_io_spec(widget_setup):
|
|
66
|
+
_, api, _ = widget_setup
|
|
67
|
+
schema = api.schemas.Widget.read.out.model_json_schema()
|
|
68
|
+
assert set(schema["properties"]) == {"id", "name", "created_at", "secret"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.i9n
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_columns_store_io_spec(widget_setup):
|
|
74
|
+
_, _, _ = widget_setup
|
|
75
|
+
spec = Widget.__tigrbl_cols__["secret"].io
|
|
76
|
+
assert spec.allow_out is False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.i9n
|
|
80
|
+
@pytest.mark.asyncio
|
|
81
|
+
async def test_default_factory_resolution(widget_setup):
|
|
82
|
+
_, _, _ = widget_setup
|
|
83
|
+
specs = Widget.__tigrbl_cols__
|
|
84
|
+
ctx = SimpleNamespace(
|
|
85
|
+
specs=specs, op="create", temp={"in_values": {}}, persist=True
|
|
86
|
+
)
|
|
87
|
+
assemble.run(None, ctx)
|
|
88
|
+
assert ctx.temp["assembled_values"]["created_at"] == "now"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.i9n
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_orm_model_carries_io_spec(widget_setup):
|
|
94
|
+
_, _, _ = widget_setup
|
|
95
|
+
assert "name" in Widget.__tigrbl_cols__
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.i9n
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_openapi_reflects_io_spec(widget_setup):
|
|
101
|
+
client, _, _ = widget_setup
|
|
102
|
+
spec = (await client.get("/openapi.json")).json()
|
|
103
|
+
props = spec["components"]["schemas"]["WidgetReadResponse"]["properties"]
|
|
104
|
+
assert "secret" in props
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.i9n
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_storage_persists_data(widget_setup):
|
|
110
|
+
client, _, SessionLocal = widget_setup
|
|
111
|
+
payload = {
|
|
112
|
+
"name": "hi",
|
|
113
|
+
"secret": "s",
|
|
114
|
+
"created_at": "now",
|
|
115
|
+
}
|
|
116
|
+
resp = await client.post("/widget/widget", json=payload)
|
|
117
|
+
wid = UUID(resp.json()["id"])
|
|
118
|
+
with SessionLocal() as session:
|
|
119
|
+
obj = session.execute(select(Widget).where(Widget.id == wid)).scalar_one()
|
|
120
|
+
assert obj.name == "hi"
|
|
121
|
+
assert obj.secret == "s"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.i9n
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_rest_calls_honor_io_spec(widget_setup):
|
|
127
|
+
client, _, _ = widget_setup
|
|
128
|
+
payload = {
|
|
129
|
+
"name": "hi",
|
|
130
|
+
"secret": "s",
|
|
131
|
+
"created_at": "now",
|
|
132
|
+
}
|
|
133
|
+
resp = await client.post("/widget/widget", json=payload)
|
|
134
|
+
wid = resp.json()["id"]
|
|
135
|
+
data = (await client.get(f"/widget/widget/{wid}")).json()
|
|
136
|
+
assert data["secret"] == "s"
|
|
137
|
+
assert data["name"] == "hi"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.i9n
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_rpc_methods_honor_io_spec(widget_setup):
|
|
143
|
+
client, _, _ = widget_setup
|
|
144
|
+
payload = {
|
|
145
|
+
"jsonrpc": "2.0",
|
|
146
|
+
"method": "Widget.create",
|
|
147
|
+
"params": {
|
|
148
|
+
"name": "rpc",
|
|
149
|
+
"secret": "x",
|
|
150
|
+
"created_at": "now",
|
|
151
|
+
},
|
|
152
|
+
"id": 1,
|
|
153
|
+
}
|
|
154
|
+
result = (await client.post("/rpc/", json=payload)).json()["result"]
|
|
155
|
+
assert result["secret"] == "x"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.i9n
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
async def test_core_crud_binding(widget_setup):
|
|
161
|
+
_, _, _ = widget_setup
|
|
162
|
+
assert Widget.hooks.create.HANDLER[0].__qualname__ == crud.create.__qualname__
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.i9n
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_hookz_reports_operations(widget_setup):
|
|
168
|
+
client, _, _ = widget_setup
|
|
169
|
+
data = (await client.get("/system/hookz")).json()
|
|
170
|
+
assert "Widget" in data
|
|
171
|
+
assert "create" in data["Widget"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.i9n
|
|
175
|
+
@pytest.mark.asyncio
|
|
176
|
+
async def test_kernelz_lists_atoms_and_steps(widget_setup):
|
|
177
|
+
client, _, _ = widget_setup
|
|
178
|
+
data = (await client.get("/system/kernelz")).json()
|
|
179
|
+
steps = data["Widget"]["create"]
|
|
180
|
+
assert "HANDLER:hook:wire:tigrbl:core:crud:ops:create@HANDLER" in steps
|
|
181
|
+
assert any("hook:sys:txn:begin@START_TX" in s for s in steps)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import pytest_asyncio
|
|
6
|
+
|
|
7
|
+
from tigrbl import TigrblApp
|
|
8
|
+
from tigrbl.orm.mixins import (
|
|
9
|
+
GUIDPk,
|
|
10
|
+
Created,
|
|
11
|
+
LastUsed,
|
|
12
|
+
ValidityWindow,
|
|
13
|
+
KeyDigest,
|
|
14
|
+
tzutcnow,
|
|
15
|
+
tzutcnow_plus_day,
|
|
16
|
+
)
|
|
17
|
+
from tigrbl.orm.mixins.utils import CRUD_IO
|
|
18
|
+
from tigrbl.orm.tables._base import Base
|
|
19
|
+
from tigrbl.specs import F, S, acol
|
|
20
|
+
from tigrbl.types import App, Mapped, String
|
|
21
|
+
from sqlalchemy import inspect
|
|
22
|
+
|
|
23
|
+
from .uvicorn_utils import run_uvicorn_in_task, stop_uvicorn_server
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiKey(Base, GUIDPk, Created, LastUsed, ValidityWindow, KeyDigest):
|
|
27
|
+
__abstract__ = False
|
|
28
|
+
__tablename__ = "apikeys_uvicorn"
|
|
29
|
+
__resource__ = "apikey"
|
|
30
|
+
|
|
31
|
+
label: Mapped[str] = acol(
|
|
32
|
+
storage=S(String, nullable=False),
|
|
33
|
+
field=F(constraints={"max_length": 120}),
|
|
34
|
+
io=CRUD_IO,
|
|
35
|
+
)
|
|
36
|
+
service_id: Mapped[str] = acol(
|
|
37
|
+
storage=S(String, nullable=False),
|
|
38
|
+
field=F(constraints={"max_length": 120}),
|
|
39
|
+
io=CRUD_IO,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest_asyncio.fixture()
|
|
44
|
+
async def running_app(sync_db_session):
|
|
45
|
+
engine, get_sync_db = sync_db_session
|
|
46
|
+
|
|
47
|
+
app = App()
|
|
48
|
+
api = TigrblApp(get_db=get_sync_db)
|
|
49
|
+
api.include_models([ApiKey])
|
|
50
|
+
await api.initialize()
|
|
51
|
+
app.include_router(api.router)
|
|
52
|
+
|
|
53
|
+
base_url, server, task = await run_uvicorn_in_task(app)
|
|
54
|
+
try:
|
|
55
|
+
yield (base_url, engine)
|
|
56
|
+
finally:
|
|
57
|
+
await stop_uvicorn_server(server, task)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _payload() -> dict:
|
|
61
|
+
now = tzutcnow()
|
|
62
|
+
return {
|
|
63
|
+
"label": "test",
|
|
64
|
+
"service_id": str(uuid4()),
|
|
65
|
+
"valid_from": now.isoformat(),
|
|
66
|
+
"valid_to": tzutcnow_plus_day().isoformat(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.i9n
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_create_apikey_success(running_app):
|
|
73
|
+
base_url, _ = running_app
|
|
74
|
+
async with httpx.AsyncClient() as client:
|
|
75
|
+
resp = await client.post(f"{base_url}/apikey", json=_payload())
|
|
76
|
+
assert resp.status_code == 201
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.i9n
|
|
80
|
+
@pytest.mark.asyncio
|
|
81
|
+
async def test_create_response_fields(running_app):
|
|
82
|
+
base_url, _ = running_app
|
|
83
|
+
async with httpx.AsyncClient() as client:
|
|
84
|
+
resp = await client.post(f"{base_url}/apikey", json=_payload())
|
|
85
|
+
body = resp.json()
|
|
86
|
+
expected = {
|
|
87
|
+
"api_key",
|
|
88
|
+
"label",
|
|
89
|
+
"service_id",
|
|
90
|
+
"valid_from",
|
|
91
|
+
"valid_to",
|
|
92
|
+
"digest",
|
|
93
|
+
"last_used_at",
|
|
94
|
+
"created_at",
|
|
95
|
+
"id",
|
|
96
|
+
}
|
|
97
|
+
assert set(body) == expected
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.i9n
|
|
101
|
+
@pytest.mark.asyncio
|
|
102
|
+
async def test_persisted_columns(running_app):
|
|
103
|
+
base_url, engine = running_app
|
|
104
|
+
async with httpx.AsyncClient() as client:
|
|
105
|
+
await client.post(f"{base_url}/apikey", json=_payload())
|
|
106
|
+
inspector = inspect(engine)
|
|
107
|
+
cols = {col["name"] for col in inspector.get_columns("apikeys_uvicorn")}
|
|
108
|
+
expected = {
|
|
109
|
+
"label",
|
|
110
|
+
"service_id",
|
|
111
|
+
"valid_from",
|
|
112
|
+
"valid_to",
|
|
113
|
+
"digest",
|
|
114
|
+
"last_used_at",
|
|
115
|
+
"created_at",
|
|
116
|
+
"id",
|
|
117
|
+
}
|
|
118
|
+
assert cols == expected
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.i9n
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_read_excludes_api_key(running_app):
|
|
124
|
+
base_url, _ = running_app
|
|
125
|
+
async with httpx.AsyncClient() as client:
|
|
126
|
+
resp = await client.post(f"{base_url}/apikey", json=_payload())
|
|
127
|
+
created = resp.json()
|
|
128
|
+
fetched = await client.get(f"{base_url}/apikey/{created['id']}")
|
|
129
|
+
body = fetched.json()
|
|
130
|
+
assert "api_key" not in body
|
|
131
|
+
assert body["digest"] == created["digest"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@pytest.mark.i9n
|
|
135
|
+
@pytest.mark.asyncio
|
|
136
|
+
async def test_rejects_digest_in_request(running_app):
|
|
137
|
+
base_url, _ = running_app
|
|
138
|
+
bad = _payload() | {"digest": "x"}
|
|
139
|
+
async with httpx.AsyncClient() as client:
|
|
140
|
+
resp = await client.post(f"{base_url}/apikey", json=bad)
|
|
141
|
+
assert resp.status_code == 422
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.i9n
|
|
145
|
+
@pytest.mark.asyncio
|
|
146
|
+
async def test_rejects_api_key_in_request(running_app):
|
|
147
|
+
base_url, _ = running_app
|
|
148
|
+
bad = _payload() | {"api_key": "raw"}
|
|
149
|
+
async with httpx.AsyncClient() as client:
|
|
150
|
+
resp = await client.post(f"{base_url}/apikey", json=bad)
|
|
151
|
+
assert resp.status_code == 422
|