pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
222 lines
7.5 KiB
222 lines
7.5 KiB
"""
|
|
Tests for correct model_fields_set and default handling in Form endpoints.
|
|
|
|
Covers:
|
|
- Absent fields (never sent by the client)
|
|
- Blank strings (HTML forms send "" for empty text inputs)
|
|
- Required fields still return 422 when absent
|
|
- Behaviour matches JSON body endpoints
|
|
- Aliases work correctly
|
|
|
|
Related: https://github.com/fastapi/fastapi/issues/13399
|
|
"""
|
|
|
|
from typing import Annotated
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, Form
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import BaseModel, Field
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BoolModel(BaseModel):
|
|
"""Checkbox-style: bool with non-falsy default."""
|
|
|
|
checkbox: bool = True
|
|
|
|
|
|
class StringModel(BaseModel):
|
|
"""Text input with a non-empty default."""
|
|
|
|
text: str = "default"
|
|
alias_field: str = Field(alias="with", default="nothing")
|
|
|
|
|
|
class MixedModel(BaseModel):
|
|
field_bool: bool = True
|
|
field_str: str = "hello"
|
|
field_nullable: str | None = None
|
|
|
|
|
|
class RequiredModel(BaseModel):
|
|
required_field: str
|
|
optional_field: str = "default"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.post("/bool")
|
|
async def bool_endpoint(model: Annotated[BoolModel, Form()]):
|
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
|
|
|
|
|
|
@app.post("/string")
|
|
async def string_endpoint(model: Annotated[StringModel, Form()]):
|
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump(by_alias=True)}
|
|
|
|
|
|
@app.post("/mixed")
|
|
async def mixed_endpoint(model: Annotated[MixedModel, Form()]):
|
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
|
|
|
|
|
|
@app.post("/required")
|
|
async def required_endpoint(model: Annotated[RequiredModel, Form()]):
|
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
|
|
|
|
|
|
# JSON body counterpart for parity checks
|
|
@app.post("/mixed-json")
|
|
async def mixed_json_endpoint(model: MixedModel):
|
|
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Absent fields — never sent by the client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_absent_bool_field_not_in_fields_set():
|
|
"""Unchecked checkbox (absent from form) must NOT appear in model_fields_set."""
|
|
resp = client.post("/bool", data={})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["fields_set"] == []
|
|
|
|
|
|
def test_absent_bool_field_default_still_applied():
|
|
"""Default value is still present in the response even when field not submitted."""
|
|
resp = client.post("/bool", data={})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["values"]["checkbox"] is True
|
|
|
|
|
|
def test_submitted_bool_field_in_fields_set():
|
|
"""Submitted checkbox appears in model_fields_set."""
|
|
resp = client.post("/bool", data={"checkbox": "false"})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["fields_set"] == ["checkbox"]
|
|
assert resp.json()["values"]["checkbox"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Blank string fields — HTML forms send "" for empty text inputs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_blank_string_treated_as_unset():
|
|
"""Empty text input ('') must NOT appear in model_fields_set; default applied."""
|
|
resp = client.post("/string", data={"text": "", "with": ""})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["fields_set"] == []
|
|
assert body["values"]["text"] == "default"
|
|
assert body["values"]["with"] == "nothing"
|
|
|
|
|
|
def test_non_empty_string_in_fields_set():
|
|
"""Non-empty submission must appear in model_fields_set."""
|
|
resp = client.post("/string", data={"text": "hello"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert "text" in body["fields_set"]
|
|
assert body["values"]["text"] == "hello"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mixed model — no data submitted at all
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_no_data_fields_set_empty():
|
|
"""Submitting no form data produces an empty model_fields_set."""
|
|
resp = client.post("/mixed", data={})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["fields_set"] == []
|
|
|
|
|
|
def test_partial_data_only_submitted_fields_in_set():
|
|
"""Only submitted fields appear in model_fields_set."""
|
|
resp = client.post("/mixed", data={"field_bool": "false"})
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["fields_set"] == ["field_bool"]
|
|
|
|
|
|
def test_all_data_all_fields_in_set():
|
|
"""All submitted fields appear in model_fields_set."""
|
|
resp = client.post(
|
|
"/mixed",
|
|
data={"field_bool": "true", "field_str": "world", "field_nullable": "x"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["fields_set"] == ["field_bool", "field_nullable", "field_str"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Required fields — must still return 422 when absent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_required_field_absent_returns_422():
|
|
"""Required field absent from form must still return a 422 validation error."""
|
|
resp = client.post("/required", data={})
|
|
assert resp.status_code == 422, resp.text
|
|
detail = resp.json()["detail"]
|
|
locs = [e["loc"] for e in detail]
|
|
assert ["body", "required_field"] in locs
|
|
|
|
|
|
def test_required_field_present_optional_absent():
|
|
"""Required field present, optional absent → only required field in fields_set."""
|
|
resp = client.post("/required", data={"required_field": "value"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["fields_set"] == ["required_field"]
|
|
assert body["values"]["optional_field"] == "default"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parity with JSON body
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"submitted, expected_set",
|
|
[
|
|
({}, []),
|
|
({"field_bool": "true"}, ["field_bool"]),
|
|
({"field_str": "world"}, ["field_str"]),
|
|
(
|
|
{"field_bool": "false", "field_str": "hi", "field_nullable": "x"},
|
|
["field_bool", "field_nullable", "field_str"],
|
|
),
|
|
],
|
|
)
|
|
def test_form_fields_set_matches_json(submitted, expected_set):
|
|
"""Form and JSON endpoints produce the same model_fields_set for equivalent input."""
|
|
form_resp = client.post("/mixed", data=submitted)
|
|
json_payload = {}
|
|
if "field_bool" in submitted:
|
|
json_payload["field_bool"] = submitted["field_bool"].lower() == "true"
|
|
if "field_str" in submitted:
|
|
json_payload["field_str"] = submitted["field_str"]
|
|
if "field_nullable" in submitted:
|
|
json_payload["field_nullable"] = submitted["field_nullable"]
|
|
|
|
json_resp = client.post("/mixed-json", json=json_payload)
|
|
|
|
assert form_resp.status_code == 200, form_resp.text
|
|
assert json_resp.status_code == 200, json_resp.text
|
|
assert form_resp.json()["fields_set"] == json_resp.json()["fields_set"] == expected_set
|
|
|