Update tests

This commit is contained in:
Yurii Motov 2025-11-02 09:52:50 +01:00
parent 8033b02f64
commit 4c68df1804
2 changed files with 193 additions and 326 deletions

193
tests/test_form_default.py Normal file
View File

@ -0,0 +1,193 @@
from typing import Any, List, Optional
import pytest
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from typing_extensions import Annotated
from .utils import needs_pydanticv2
class FormParams(BaseModel):
param_1: str = "default"
param_2: Annotated[str, Field(alias="param_2_alias")] = "default"
param_3: Annotated[str, Form(alias="param_3_alias")] = "default"
upload_file: Optional[UploadFile] = None
file: Annotated[Optional[bytes], File()] = None
app = FastAPI()
@app.post("/form-param")
def form_param(
param_1: Annotated[str, Form()] = "default",
param_2: Annotated[str, Form(alias="param_2_alias")] = "default",
upload_file: Optional[UploadFile] = None,
file: Annotated[Optional[bytes], File()] = None,
):
return {
"param_1": param_1,
"param_2": param_2,
"upload_file": upload_file,
"file": file,
}
@app.post("/form-model")
def form_model(params: Annotated[FormParams, Form()]):
return {
"param_1": params.param_1,
"param_2": params.param_2,
"param_3": params.param_3,
"upload_file": params.upload_file,
"file": params.file,
}
@app.post("/form-param-list-with-default")
def form_param_list_with_default(
params: Annotated[List[str], Form(default_factory=list)],
):
return {"params": params}
@app.post("/form-param-optional-list")
def form_param_optional_list(
params: Annotated[Optional[List[str]], Form()] = None,
):
return {"params": params}
@app.post("/form-model-fields-set")
def form_model_fields_set(params: Annotated[FormParams, Form()]):
return {"fields_set": params.model_fields_set}
# ====================================================================================
# Tests
@pytest.fixture(scope="module")
def client():
with TestClient(app) as test_client:
yield test_client
@pytest.mark.parametrize(
"data",
[
pytest.param({}, id="no_data_sent"),
pytest.param(
{"param_1": "", "param_2_alias": "", "upload_file": "", "file": ""},
id="empty_strings_sent",
),
],
)
def test_defaults_form_param(client: TestClient, data: dict):
"""
Empty string or no input data - default value is used.
For parameters declared as single Form fields.
"""
response = client.post("/form-param", data=data)
assert response.json() == {
"param_1": "default",
"param_2": "default",
"upload_file": None,
"file": None,
}
@pytest.mark.parametrize(
"data",
[
pytest.param({}, id="no_data_sent"),
pytest.param(
{
"param_1": "",
"param_2_alias": "",
"param_3_alias": "",
"upload_file": "",
"file": "",
},
id="empty_strings_sent",
),
],
)
def test_defaults_form_model(client: TestClient, data: dict):
"""
Empty string or no data - default value is used.
For parameters declared as Form model.
"""
response = client.post("/form-model", data=data)
assert response.json() == {
"param_1": "default",
"param_2": "default",
"param_3": "default",
"upload_file": None,
"file": None,
}
@pytest.mark.parametrize(
("url", "expected_res"),
[
("/form-param-list-with-default", []),
("/form-param-optional-list", None),
],
)
def test_form_list_param_no_input(client: TestClient, url: str, expected_res: Any):
"""
No input data passed to list parameter - default value is used.
"""
response = client.post(url)
assert response.json() == {"params": expected_res}
@pytest.mark.parametrize(
("data", "expected_res"),
[
({"params": ""}, [""]),
({"params": [""]}, [""]),
({"params": ["", "a"]}, ["", "a"]),
({"params": ["a", ""]}, ["a", ""]),
],
)
@pytest.mark.parametrize(
"url", ["/form-param-list-with-default", "/form-param-optional-list"]
)
def test_form_list_param_empty_str(
client: TestClient, url: str, data: dict, expected_res: Any
):
"""
Empty strings passed to list parameter treated as empty strings.
"""
response = client.post(url, data=data)
assert response.json() == {"params": expected_res}
@pytest.mark.parametrize(
"data",
[
pytest.param({}, id="no_data_sent"),
pytest.param(
{
"param_1": "",
"param_2_alias": "",
"param_3_alias": "",
"upload_file": "",
"file": "",
},
id="empty_strings_sent",
),
],
)
@needs_pydanticv2
def test_form_model_fields_set(client: TestClient, data: dict):
"""
Check that fields are not pre-populated with default values when no data sent or
empty string sent.
"""
response = client.post("/form-model-fields-set", data=data)
assert response.json() == {"fields_set": []}

View File

@ -1,326 +0,0 @@
from typing import Optional
import pytest
from fastapi import FastAPI, Form
from fastapi._compat import PYDANTIC_V2
from pydantic import BaseModel, Field
from starlette.testclient import TestClient
from typing_extensions import Annotated
from .utils import needs_pydanticv2
if PYDANTIC_V2:
from pydantic import model_validator
else:
from pydantic import root_validator
def _validate_input(value: dict) -> dict:
"""
model validators in before mode should receive values passed
to model instantiation before any further validation
"""
# we should not be double-instantiating the models
assert isinstance(value, dict)
value["init_input"] = value.copy()
# differentiate between explicit Nones and unpassed values
if "true_if_unset" not in value:
value["true_if_unset"] = True
return value
class Parent(BaseModel):
init_input: dict
# importantly, no default here
if PYDANTIC_V2:
@model_validator(mode="before")
def validate_inputs(cls, value: dict) -> dict:
return _validate_input(value)
else:
@root_validator(pre=True)
def validate_inputs(cls, value: dict) -> dict:
return _validate_input(value)
class StandardModel(Parent):
default_true: bool = True
default_false: bool = False
default_none: Optional[bool] = None
default_zero: int = 0
default_str: str = "foo"
true_if_unset: Optional[bool] = None
class FieldModel(Parent):
default_true: bool = Field(default=True)
default_false: bool = Field(default=False)
default_none: Optional[bool] = Field(default=None)
default_zero: int = Field(default=0)
default_str: str = Field(default="foo")
true_if_unset: Optional[bool] = Field(default=None)
if PYDANTIC_V2:
class AnnotatedFieldModel(Parent):
default_true: Annotated[bool, Field(default=True)]
default_false: Annotated[bool, Field(default=False)]
default_none: Annotated[Optional[bool], Field(default=None)]
default_zero: Annotated[int, Field(default=0)]
default_str: Annotated[str, Field(default="foo")]
true_if_unset: Annotated[Optional[bool], Field(default=None)]
class AnnotatedFormModel(Parent):
default_true: Annotated[bool, Form(default=True)]
default_false: Annotated[bool, Form(default=False)]
default_none: Annotated[Optional[bool], Form(default=None)]
default_zero: Annotated[int, Form(default=0)]
default_str: Annotated[str, Form(default="foo")]
true_if_unset: Annotated[Optional[bool], Form(default=None)]
class SimpleForm(BaseModel):
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
foo: Annotated[str, Form(default="bar")]
alias_with: Annotated[str, Form(alias="with", default="nothing")]
class ResponseModel(BaseModel):
fields_set: list = Field(default_factory=list)
dumped_fields_no_exclude: dict = Field(default_factory=dict)
dumped_fields_exclude_default: dict = Field(default_factory=dict)
dumped_fields_exclude_unset: dict = Field(default_factory=dict)
dumped_fields_no_meta: dict = Field(default_factory=dict)
init_input: dict
@classmethod
def from_value(cls, value: Parent) -> "ResponseModel":
if PYDANTIC_V2:
return ResponseModel(
init_input=value.init_input,
fields_set=list(value.model_fields_set),
dumped_fields_no_exclude=value.model_dump(),
dumped_fields_exclude_default=value.model_dump(exclude_defaults=True),
dumped_fields_exclude_unset=value.model_dump(exclude_unset=True),
dumped_fields_no_meta=value.model_dump(
exclude={"init_input", "fields_set"}
),
)
else:
return ResponseModel(
init_input=value.init_input,
fields_set=list(value.__fields_set__),
dumped_fields_no_exclude=value.dict(),
dumped_fields_exclude_default=value.dict(exclude_defaults=True),
dumped_fields_exclude_unset=value.dict(exclude_unset=True),
dumped_fields_no_meta=value.dict(exclude={"init_input", "fields_set"}),
)
app = FastAPI()
@app.post("/form/standard")
async def form_standard(value: Annotated[StandardModel, Form()]) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/field")
async def form_field(value: Annotated[FieldModel, Form()]) -> ResponseModel:
return ResponseModel.from_value(value)
if PYDANTIC_V2:
@app.post("/form/annotated-field")
async def form_annotated_field(
value: Annotated[AnnotatedFieldModel, Form()],
) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/annotated-form")
async def form_annotated_form(
value: Annotated[AnnotatedFormModel, Form()],
) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/inlined")
async def form_inlined(
default_true: Annotated[bool, Form()] = True,
default_false: Annotated[bool, Form()] = False,
default_none: Annotated[Optional[bool], Form()] = None,
default_zero: Annotated[int, Form()] = 0,
default_str: Annotated[str, Form()] = "foo",
true_if_unset: Annotated[Optional[bool], Form()] = None,
):
"""
Rather than using a model, inline the fields in the endpoint.
This doesn't use the `ResponseModel` pattern, since that is just to
test the instantiation behavior prior to the endpoint function.
Since we are receiving the values of the fields here (and thus,
having the defaults is correct behavior), we just return the values.
"""
if true_if_unset is None:
true_if_unset = True
return {
"default_true": default_true,
"default_false": default_false,
"default_none": default_none,
"default_zero": default_zero,
"default_str": default_str,
"true_if_unset": true_if_unset,
}
@app.post("/json/standard")
async def json_standard(value: StandardModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/json/field")
async def json_field(value: FieldModel) -> ResponseModel:
return ResponseModel.from_value(value)
if PYDANTIC_V2:
@app.post("/json/annotated-field")
async def json_annotated_field(value: AnnotatedFieldModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/json/annotated-form")
async def json_annotated_form(value: AnnotatedFormModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/simple-form")
def form_endpoint(model: Annotated[SimpleForm, Form()]) -> dict:
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
return model.model_dump()
if PYDANTIC_V2:
MODEL_TYPES = {
"standard": StandardModel,
"field": FieldModel,
"annotated-field": AnnotatedFieldModel,
"annotated-form": AnnotatedFormModel,
}
else:
MODEL_TYPES = {
"standard": StandardModel,
"field": FieldModel,
}
ENCODINGS = ("form", "json")
@pytest.fixture(scope="module")
def client() -> TestClient:
with TestClient(app) as test_client:
yield test_client
@pytest.mark.parametrize("encoding", ENCODINGS)
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
def test_no_prefill_defaults_all_unset(encoding, model_type, client):
"""
When the model is instantiated by the server, it should not have its defaults prefilled
"""
endpoint = f"/{encoding}/{model_type}"
if encoding == "form":
res = client.post(endpoint, data={})
else:
res = client.post(endpoint, json={})
assert res.status_code == 200
response_model = ResponseModel(**res.json())
assert response_model.init_input == {}
assert len(response_model.fields_set) == 2
assert response_model.dumped_fields_no_exclude["true_if_unset"] is True
@pytest.mark.parametrize("encoding", ENCODINGS)
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
def test_no_prefill_defaults_partially_set(encoding, model_type, client):
"""
When the model is instantiated by the server, it should not have its defaults prefilled,
and pydantic should be able to differentiate between unset and default values when some are passed
"""
endpoint = f"/{encoding}/{model_type}"
if encoding == "form":
data = {"true_if_unset": "False", "default_false": "True", "default_zero": "0"}
res = client.post(endpoint, data=data)
else:
data = {"true_if_unset": False, "default_false": True, "default_zero": 0}
res = client.post(endpoint, json=data)
if PYDANTIC_V2:
dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump(
exclude_unset=True
)
dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump(
exclude_defaults=True
)
else:
dumped_exclude_unset = MODEL_TYPES[model_type](**data).dict(exclude_unset=True)
dumped_exclude_default = MODEL_TYPES[model_type](**data).dict(
exclude_defaults=True
)
assert res.status_code == 200
response_model = ResponseModel(**res.json())
assert response_model.init_input == data
assert len(response_model.fields_set) == 4
assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset
assert response_model.dumped_fields_no_exclude["true_if_unset"] is False
assert "default_zero" not in dumped_exclude_default
assert "default_zero" not in response_model.dumped_fields_exclude_default
@needs_pydanticv2
def test_casted_empty_defaults(client: TestClient):
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
form_content = {"foo": "", "with": ""}
response = client.post("/simple-form", data=form_content)
response_content = response.json()
assert response_content["foo"] == "bar" # Expected :'bar' -> Actual :''
assert response_content["alias_with"] == "nothing" # ok
@pytest.mark.parametrize("model_type", list(MODEL_TYPES.keys()) + ["inlined"])
def test_empty_string_inputs(model_type, client):
"""
Form inputs with no input are empty strings,
these should be treated as being unset.
"""
data = {
"default_true": "",
"default_false": "",
"default_none": "",
"default_str": "",
"default_zero": "",
"true_if_unset": "",
}
response = client.post(f"/form/{model_type}", data=data)
assert response.status_code == 200
if model_type != "inlined":
response_model = ResponseModel(**response.json())
assert set(response_model.fields_set) == {"true_if_unset", "init_input"}
response_data = response_model.dumped_fields_no_meta
else:
response_data = response.json()
assert response_data == {
"default_true": True,
"default_false": False,
"default_none": None,
"default_zero": 0,
"default_str": "foo",
"true_if_unset": True,
}